use std::io::{self, BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::ExitStatus;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use super::record::{Line, Outcome, Record, Stream};
static JSONL: AtomicBool = AtomicBool::new(false);
static SEQ: AtomicU64 = AtomicU64::new(0);
static SUBPROCESS: AtomicU64 = AtomicU64::new(0);
static STARTED: OnceLock<Instant> = OnceLock::new();
fn writer() -> &'static Mutex<()> {
static WRITER: OnceLock<Mutex<()>> = OnceLock::new();
WRITER.get_or_init(|| Mutex::new(()))
}
static TRANSCRIPT: OnceLock<Option<crate::logs::op_log::OpLogWriter>> = OnceLock::new();
pub const NO_TRANSCRIPT_ENV: &str = "WSB_NO_TRANSCRIPT";
static RUN: OnceLock<Option<super::activity::Run>> = OnceLock::new();
enum Reporting {
Waiting { held: Vec<Line>, dropped: u64 },
Live(super::activity::Reporter),
Off,
}
const HELD_BEFORE_DECIDING: usize = 2000;
fn reporting() -> &'static Mutex<Reporting> {
static REPORTING: OnceLock<Mutex<Reporting>> = OnceLock::new();
REPORTING.get_or_init(|| {
Mutex::new(Reporting::Waiting {
held: Vec::new(),
dropped: 0,
})
})
}
pub fn report_to_daemon(client: &crate::daemon::Client) {
let Some(Some(run)) = RUN.get() else {
return;
};
let mut state = match reporting().lock() {
Ok(state) => state,
Err(_) => return,
};
let Reporting::Waiting { held, dropped } = &mut *state else {
return;
};
let held = std::mem::take(held);
let dropped = *dropped;
match super::activity::Reporter::start(client.clone(), run.clone(), held, dropped) {
Ok(reporter) => *state = Reporting::Live(reporter),
Err(_) => *state = Reporting::Off,
}
}
fn report_line(line: &Line) {
let Ok(mut state) = reporting().lock() else {
return;
};
match &mut *state {
Reporting::Waiting { held, dropped } => {
held.push(line.clone());
while held.len() > HELD_BEFORE_DECIDING {
held.remove(0);
*dropped += 1;
}
}
Reporting::Live(reporter) => reporter.line(line),
Reporting::Off => {}
}
}
fn close_report(exit_code: i32) {
let Ok(mut state) = reporting().lock() else {
return;
};
if let Reporting::Live(reporter) = std::mem::replace(&mut *state, Reporting::Off) {
drop(state);
reporter.finished(exit_code);
}
}
pub fn attach_transcript(workspace_root: &std::path::Path, argv: &[String]) -> Option<PathBuf> {
if std::env::var_os(NO_TRANSCRIPT_ENV).is_some() {
let _ = TRANSCRIPT.set(None);
let _ = RUN.set(None);
return None;
}
let logs_root = crate::logs::root(workspace_root);
let kind = argv
.iter()
.skip(1)
.find(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_else(|| "wsb".to_string());
let label = std::iter::once(
std::path::Path::new(argv.first().map(String::as_str).unwrap_or("wsb"))
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "wsb".to_string()),
)
.chain(argv.iter().skip(1).cloned())
.collect::<Vec<_>>()
.join(" ");
let id = uuid::Uuid::new_v4();
let started_at = chrono::Utc::now();
let writer = crate::logs::op_log::OpLogWriter::create(
&logs_root,
id,
&kind,
&label,
argv,
started_at,
);
match writer {
Ok(writer) => {
let path = writer.path().to_path_buf();
let _ = RUN.set(Some(super::activity::Run {
id,
kind,
label,
argv: argv.to_vec(),
started_at,
log_path: path.display().to_string(),
}));
let _ = TRANSCRIPT.set(Some(writer));
Some(path)
}
Err(error) => {
let _ = writeln!(io::stderr(), "[wsb] this run is not being recorded: {error}");
let _ = TRANSCRIPT.set(None);
let _ = RUN.set(None);
None
}
}
}
fn close_transcript(exit_code: i32) {
close_report(exit_code);
let Some(Some(writer)) = TRANSCRIPT.get() else {
return;
};
let status = if exit_code == 0 {
crate::logs::op_log::Status::Completed
} else {
crate::logs::op_log::Status::Failed
};
let error = (exit_code != 0).then(|| format!("exited with status {exit_code}"));
writer.finish(status, error);
}
fn record_line(line: &Line) {
if let Some(Some(writer)) = TRANSCRIPT.get() {
writer.write_line(line);
}
report_line(line);
}
pub fn is_jsonl() -> bool {
JSONL.load(Ordering::Relaxed)
}
pub fn jsonl_mode() {
JSONL.store(true, Ordering::Relaxed);
colored::control::set_override(false);
}
pub fn take_jsonl_flag(argv: &mut Vec<String>) -> bool {
let before = argv.len();
argv.retain(|arg| arg != "--jsonl");
before != argv.len()
}
pub fn started(argv: &[String]) {
let _ = STARTED.set(Instant::now());
if !is_jsonl() {
return;
}
emit(Record::CommandStarted {
argv: argv.to_vec(),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default(),
version: env!("CARGO_PKG_VERSION").to_string(),
pid: std::process::id(),
});
}
pub fn finish(exit_code: i32) -> i32 {
if is_jsonl() {
let outcome = if exit_code == 0 {
Outcome::Ok
} else {
Outcome::Failed
};
emit(Record::CommandFinished {
outcome,
exit_code,
duration_ms: elapsed_ms(),
});
}
let _ = io::stdout().flush();
close_transcript(exit_code);
exit_code
}
fn elapsed_ms() -> u64 {
STARTED
.get()
.map(|start| start.elapsed().as_millis() as u64)
.unwrap_or(0)
}
pub(super) fn next_subprocess_id() -> u64 {
SUBPROCESS.fetch_add(1, Ordering::Relaxed) + 1
}
pub fn emit(record: Record) {
let line = Line {
seq: SEQ.fetch_add(1, Ordering::Relaxed) + 1,
ts: crate::logging::rfc3339_now(),
record,
};
record_line(&line);
if !is_jsonl() {
return;
}
let encoded = match serde_json::to_string(&line) {
Ok(encoded) => encoded,
Err(error) => {
let _ = writeln!(io::stderr(), "[wsb] could not encode an output record: {error}");
return;
}
};
let _guard = writer().lock();
let mut out = io::stdout().lock();
let _ = writeln!(out, "{encoded}");
let _ = out.flush();
}
pub fn write_out(text: &str) {
if is_jsonl() {
for line in split_lines(text) {
emit(Record::Stdout { text: line });
}
return;
}
if TRANSCRIPT.get().is_some_and(Option::is_some) {
for line in split_lines(text) {
record_line(&Line {
seq: SEQ.fetch_add(1, Ordering::Relaxed) + 1,
ts: crate::logging::rfc3339_now(),
record: Record::Stdout { text: line },
});
}
}
let _guard = writer().lock();
let mut out = io::stdout().lock();
let _ = out.write_all(text.as_bytes());
let _ = out.flush();
}
pub fn write_err(text: &str) {
if is_jsonl() {
for line in split_lines(text) {
emit(Record::Stderr { text: line });
}
return;
}
if TRANSCRIPT.get().is_some_and(Option::is_some) {
for line in split_lines(text) {
record_line(&Line {
seq: SEQ.fetch_add(1, Ordering::Relaxed) + 1,
ts: crate::logging::rfc3339_now(),
record: Record::Stderr { text: line },
});
}
}
let _guard = writer().lock();
let mut err = io::stderr().lock();
let _ = err.write_all(text.as_bytes());
let _ = err.flush();
}
pub fn record_err(text: &str) {
if is_jsonl() {
for line in split_lines(text) {
emit(Record::Stderr { text: line });
}
return;
}
if TRANSCRIPT.get().is_some_and(Option::is_some) {
for line in split_lines(text) {
record_line(&Line {
seq: SEQ.fetch_add(1, Ordering::Relaxed) + 1,
ts: crate::logging::rfc3339_now(),
record: Record::Stderr { text: line },
});
}
}
}
#[derive(Default)]
pub struct SettledLines {
pending: Vec<u8>,
}
impl SettledLines {
pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
let mut settled = Vec::new();
for &byte in bytes {
if byte == b'\n' {
if let Some(line) = self.take() {
settled.push(line);
}
} else {
self.pending.push(byte);
}
}
settled
}
pub fn finish(mut self) -> Option<String> {
self.take()
}
fn take(&mut self) -> Option<String> {
let text = String::from_utf8_lossy(&self.pending).into_owned();
self.pending.clear();
let last = text.rsplit('\r').next().unwrap_or_default();
let last = strip_ansi(last);
let last = last.trim_end();
(!last.trim().is_empty()).then(|| last.to_string())
}
}
pub fn pass_live_display(mut stream: impl io::Read, terminal: &mut impl io::Write) {
let mut lines = SettledLines::default();
let mut buffer = [0u8; 4096];
loop {
let read = match stream.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => read,
};
let _ = terminal.write_all(&buffer[..read]);
let _ = terminal.flush();
for line in lines.feed(&buffer[..read]) {
record_err(&line);
}
}
if let Some(line) = lines.finish() {
record_err(&line);
}
}
fn strip_ansi(text: &str) -> String {
if !text.contains('\x1b') {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
match chars.next() {
Some('[') => {
for c in chars.by_ref() {
if ('\x40'..='\x7e').contains(&c) {
break;
}
}
}
Some(']') => {
while let Some(c) = chars.next() {
if c == '\x07' {
break;
}
if c == '\x1b' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
Some(_) | None => {}
}
}
out
}
fn split_lines(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
let trimmed = text.strip_suffix('\n').unwrap_or(text);
if trimmed.is_empty() {
return vec![String::new()];
}
trimmed.split('\n').map(strip_ansi).collect()
}
pub(super) fn finished_child(id: u64, status: &ExitStatus, started: Instant) {
emit(Record::SubprocessFinished {
id,
exit_code: status.code(),
signal: exit_signal(status),
duration_ms: started.elapsed().as_millis() as u64,
});
}
pub(super) fn transcribing() -> bool {
TRANSCRIPT.get().is_some_and(Option::is_some)
}
pub(super) fn pump<R: io::Read>(id: u64, stream: Stream, pipe: R, echo: bool) {
let mut reader = BufReader::new(pipe);
let mut buffer = Vec::new();
loop {
buffer.clear();
match reader.read_until(b'\n', &mut buffer) {
Ok(0) => return,
Ok(_) => {
if echo {
let _guard = writer().lock();
let _ = match stream {
Stream::Stdout => io::stdout().lock().write_all(&buffer),
Stream::Stderr => io::stderr().lock().write_all(&buffer),
};
}
while matches!(buffer.last(), Some(b'\n') | Some(b'\r')) {
buffer.pop();
}
let text = strip_ansi(&String::from_utf8_lossy(&buffer));
emit(Record::SubprocessOutput { id, stream, text });
}
Err(error) => {
emit(Record::SubprocessOutput {
id,
stream,
text: format!("[wsb: could not read this process's output: {error}]"),
});
return;
}
}
}
}
fn exit_signal(status: &ExitStatus) -> Option<i32> {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
return status.signal();
}
#[cfg(not(unix))]
{
let _ = status;
None
}
}
#[macro_export]
macro_rules! outln {
() => { $crate::output::write_out("\n") };
($($arg:tt)*) => { $crate::output::write_out(&format!("{}\n", format_args!($($arg)*))) };
}
#[macro_export]
macro_rules! out {
($($arg:tt)*) => { $crate::output::write_out(&format!("{}", format_args!($($arg)*))) };
}
#[macro_export]
macro_rules! errln {
() => { $crate::output::write_err("\n") };
($($arg:tt)*) => { $crate::output::write_err(&format!("{}\n", format_args!($($arg)*))) };
}
#[macro_export]
macro_rules! err {
($($arg:tt)*) => { $crate::output::write_err(&format!("{}", format_args!($($arg)*))) };
}
#[cfg(test)]
#[path = "sink_tests.rs"]
mod tests;