wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What a run reports about itself, and what it deliberately does not.

use super::*;
use std::sync::{Arc, Mutex};

use crate::output::record::Record;

/// What the reporter said, in order.
#[derive(Debug, Default)]
struct Said {
    announced: Option<(usize, u64)>,
    lines: Vec<String>,
    finished: Option<i32>,
    /// Set to refuse the announcement, which is what a daemon that has gone
    /// away looks like from here.
    refuse_announcement: bool,
}

#[derive(Clone)]
struct Recorder(Arc<Mutex<Said>>);

impl Recorder {
    fn new() -> Self {
        Self(Arc::new(Mutex::new(Said::default())))
    }

    fn refusing() -> Self {
        let recorder = Self::new();
        recorder.0.lock().unwrap().refuse_announcement = true;
        recorder
    }

    fn said(&self) -> std::sync::MutexGuard<'_, Said> {
        self.0.lock().unwrap()
    }
}

impl Report for Recorder {
    fn announce(&self, _run: &Run, preface: &[Line], dropped: u64) -> Result<(), String> {
        let mut said = self.0.lock().unwrap();
        if said.refuse_announcement {
            return Err("this daemon is not answering".to_string());
        }
        said.announced = Some((preface.len(), dropped));
        for line in preface {
            said.lines.push(text_of(line));
        }
        Ok(())
    }

    fn lines(&self, _run: &Run, lines: &[Line]) -> Result<(), String> {
        let mut said = self.0.lock().unwrap();
        for line in lines {
            said.lines.push(text_of(line));
        }
        Ok(())
    }

    fn finished(&self, _run: &Run, exit_code: i32) -> Result<(), String> {
        self.0.lock().unwrap().finished = Some(exit_code);
        Ok(())
    }
}

fn text_of(line: &Line) -> String {
    match &line.record {
        Record::Stdout { text } => text.clone(),
        other => format!("{other:?}"),
    }
}

fn line(text: &str) -> Line {
    Line {
        seq: 0,
        ts: "2026-08-29T00:00:00Z".to_string(),
        record: Record::Stdout {
            text: text.to_string(),
        },
    }
}

fn a_run() -> Run {
    Run {
        id: uuid::Uuid::new_v4(),
        kind: "update".to_string(),
        label: "wsb update --recursive".to_string(),
        argv: vec![
            "wsb".to_string(),
            "update".to_string(),
            "--recursive".to_string(),
        ],
        started_at: chrono::Utc::now(),
        log_path: "/tmp/logs/ops/2026-08-29/update.jsonl".to_string(),
    }
}

/// A threshold short enough to keep the suite quick and long enough that a
/// scheduler hiccup does not decide the outcome.
const SOON: Duration = Duration::from_millis(120);

/// TEST11216: a run that is over before it was worth watching says nothing.
///
/// The reason this module has a threshold at all. A workspace runs wsb
/// constantly — hooks, version verbs, `secret has`, `daemon status` — and a
/// panel that showed every one of them would be a panel of rows that appeared
/// and vanished, with an HTTP round trip in front of commands that are
/// otherwise instant. What makes that safe is that nothing is announced and
/// then withdrawn: a short run is never mentioned at all.
#[test]
fn test11216_a_run_that_ends_quickly_is_never_announced() {
    let recorder = Recorder::new();
    let reporter =
        Reporter::watching(recorder.clone(), a_run(), Vec::new(), 0, SOON).expect("a thread");

    reporter.line(&line("reading the workspace"));
    reporter.finished(0);

    let said = recorder.said();
    assert!(
        said.announced.is_none(),
        "a run that finished before the threshold was announced anyway"
    );
    assert!(
        said.finished.is_none(),
        "a run that was never announced reported how it ended"
    );
    assert!(said.lines.is_empty(), "lines were sent for a run nothing knows about");
}

/// TEST11217: a run that outlives the threshold is announced with everything
/// it has already said.
///
/// Waiting costs nothing only if nothing is lost by waiting. If the
/// announcement carried just the lines that came after it, a page opened on a
/// build would show it from the middle — and the beginning of a build is where
/// what it is doing is stated.
#[test]
fn test11217_the_announcement_carries_what_the_run_already_said() {
    let recorder = Recorder::new();
    let reporter = Reporter::watching(
        recorder.clone(),
        a_run(),
        vec![line("attached the transcript")],
        0,
        SOON,
    )
    .expect("a thread");

    reporter.line(&line("reading the workspace"));
    reporter.line(&line("rendering templates"));
    std::thread::sleep(SOON * 3);
    reporter.line(&line("wrote version.txt"));
    reporter.finished(0);

    let said = recorder.said();
    let (carried, dropped) = said
        .announced
        .expect("a run past the threshold must be announced");
    assert_eq!(
        carried, 3,
        "the announcement did not carry the lines said before it"
    );
    assert_eq!(dropped, 0);
    assert_eq!(
        said.lines,
        vec![
            "attached the transcript",
            "reading the workspace",
            "rendering templates",
            "wrote version.txt",
        ],
        "the run was not reported in the order it happened"
    );
    assert_eq!(said.finished, Some(0), "the run did not say how it ended");
}

/// TEST11218: a failing run is reported as one.
///
/// The exit code is the whole outcome of a command line. A panel that showed
/// every terminal run as completed would be worse than one that showed none:
/// it would say a build succeeded because it stopped.
#[test]
fn test11218_the_exit_code_is_what_the_outcome_is_taken_from() {
    let recorder = Recorder::new();
    let reporter =
        Reporter::watching(recorder.clone(), a_run(), Vec::new(), 0, SOON).expect("a thread");
    std::thread::sleep(SOON * 3);
    reporter.finished(2);

    assert_eq!(
        recorder.said().finished,
        Some(2),
        "the status the run exited with was not reported"
    );
}

/// TEST11219: a daemon that refuses the announcement does not hold up the run.
///
/// The state a machine is in while its daemon restarts, and the one thing this
/// module must never do is make it worse. Nothing is retried, nothing is
/// queued, and `finished` returns rather than waiting out its timeout — a
/// command line that hung for a second and a half at exit because a server was
/// down would be a worse bug than the missing row.
#[test]
fn test11219_a_daemon_that_refuses_does_not_delay_the_command() {
    let recorder = Recorder::refusing();
    let reporter =
        Reporter::watching(recorder.clone(), a_run(), Vec::new(), 0, SOON).expect("a thread");
    std::thread::sleep(SOON * 3);
    reporter.line(&line("still going"));

    let waited = std::time::Instant::now();
    reporter.finished(0);
    let waited = waited.elapsed();

    assert!(
        waited < Duration::from_millis(500),
        "the command waited {waited:?} on a daemon that had already refused"
    );
    let said = recorder.said();
    assert!(said.announced.is_none());
    assert!(
        said.lines.is_empty() && said.finished.is_none(),
        "lines were sent after the announcement was refused"
    );
}

/// TEST11220: a run that says more than it can hold reports what it dropped.
///
/// A truncated log presented as a whole one is the failure mode worth spending
/// a counter on: somebody looks for the line where a build went wrong, does
/// not find it, and concludes it never said it.
#[test]
fn test11220_lines_dropped_before_the_announcement_are_counted() {
    let recorder = Recorder::new();
    let reporter =
        Reporter::watching(recorder.clone(), a_run(), Vec::new(), 0, SOON).expect("a thread");

    for n in 0..(PREFACE + 25) {
        reporter.line(&line(&format!("line {n}")));
    }
    std::thread::sleep(SOON * 3);
    reporter.finished(0);

    let said = recorder.said();
    let (carried, dropped) = said.announced.expect("announced");
    assert_eq!(carried, PREFACE, "more lines were held than the cap allows");
    assert_eq!(
        dropped, 25,
        "the lines that had to be dropped were not counted"
    );
    assert_eq!(
        said.lines.first().map(String::as_str),
        Some("line 25"),
        "the OLDEST lines must be the ones dropped: a run's most recent output \
         is the part somebody is looking at"
    );
}