Skip to main content

rhei_cli/cli/
run_summary.rs

1// End-of-run console summary: after `rhei run` exits and the TUI restores the
2// terminal, print a compact, scan-first view — result line, distribution bar,
3// counts, attention, and a source-order task tree — without opening a file.
4
5// §FS-rhei-run-report.3: the renderer here is pure; `SummarySink` collects the
6// per-task data during the run.
7
8// `HashMap` and `Mutex` are already imported at the crate root (this file is
9// `include!`-ed), so they are referenced unqualified without a local `use`.
10
11/// Per-task activity accumulated from the run event stream. The tree shows the
12/// driver and timing of the work that advanced each task. §FS-rhei-run-report.3.2
13#[derive(Debug, Clone, Default)]
14struct TaskActivity {
15    /// `"agent"` or `"program"` — the driver of the last invocation for the task.
16    driver: Option<&'static str>,
17    /// Number of invocations spawned for the task (fan-out targets count > 1).
18    invocations: u32,
19    /// Duration of the last invocation, milliseconds.
20    last_duration_ms: u64,
21    /// Direct accounting for usage reported against this task during the run.
22    accounting: Option<rhei_tui::AccountingRunSummary>,
23    /// Required artifacts the last exit-0 worker left unwritten, rendered as
24    /// `name (path)`, paired with the state it left them in. The halt
25    /// classification uses them only while the ticket is still in that state,
26    /// and a fresh spawn clears them, so an old stall never explains a new one.
27    // §FS-rhei-run-report.3.1
28    missing_outputs: Option<(String, Vec<String>)>,
29}
30
31/// One spawned transition from the run event stream, rendered into the report's
32/// ledger and invocations (agent/program only; callback and terminal-at-start
33/// rows are synthesized at build time). §FS-rhei-run-report.4 §FS-rhei-run-report.7
34#[derive(Debug, Clone)]
35struct LedgerRecord {
36    task: String,
37    from: String,
38    to: String,
39    /// `"agent"` or `"program"`.
40    driver: &'static str,
41    log_path: std::path::PathBuf,
42    exit_code: Option<i32>,
43    duration_ms: u64,
44    outcome: LedgerOutcome,
45}
46
47/// The terminal disposition of a spawned invocation, mirrored from
48/// [`rhei_tui::TaskOutcome`] so the renderer does not depend on the TUI enum.
49#[derive(Debug, Clone)]
50enum LedgerOutcome {
51    Completed,
52    Failed(String),
53    Cancelled,
54    TimedOut,
55    /// The run was interrupted and the engine ended the invocation; no
56    /// transition was selected. §FS-rhei-run.3.2
57    Interrupted,
58}
59
60/// `EventSink` recording per-task driver/duration for the console task tree and
61/// the spawned-transition ledger for the durable report; teed alongside the
62/// journal/frontend sinks and read post-run. §FS-rhei-run-report.3.2 §FS-rhei-run-report.8
63pub struct SummarySink {
64    inner: Mutex<SummaryState>,
65}
66
67#[derive(Default)]
68struct SummaryState {
69    /// Driver of each in-flight slot, keyed by slot index, set on `SlotAssigned`.
70    inflight: HashMap<u16, &'static str>,
71    /// Finalized per-task activity, keyed by task id.
72    tasks: HashMap<String, TaskActivity>,
73    /// Spawned transitions in chronological order, for the durable ledger.
74    ledger: Vec<LedgerRecord>,
75    /// Usage reported during the run, used before `RunFinished` publishes the
76    /// authoritative rollup or on an early-error fallback. §FS-rhei-cost-accounting.7
77    usages: Vec<rhei_tui::UsageSummary>,
78    /// Usage grouped by direct task id for task-row cost display.
79    usage_by_task: HashMap<String, Vec<rhei_tui::UsageSummary>>,
80    /// The finalized run rollup from `RunFinished`, when available.
81    accounting: Option<rhei_tui::AccountingRunSummary>,
82}
83
84impl SummarySink {
85    pub fn new() -> Self {
86        Self { inner: Mutex::new(SummaryState::default()) }
87    }
88
89    /// Snapshot the accumulated activity for rendering after the run. A poisoned
90    /// lock (a worker panicked mid-run) degrades to empty rather than panicking
91    /// the report — a partial report still beats none.
92    fn snapshot(&self) -> HashMap<String, TaskActivity> {
93        self.inner.lock().map(|state| state.tasks.clone()).unwrap_or_default()
94    }
95
96    /// The spawned-transition ledger in chronological order; empty on a poisoned
97    /// lock, for the same best-effort reason as [`snapshot`](Self::snapshot).
98    fn ledger(&self) -> Vec<LedgerRecord> {
99        self.inner.lock().map(|state| state.ledger.clone()).unwrap_or_default()
100    }
101
102    /// Run-level accounting, preferring the finalized `RunFinished` summary and
103    /// falling back to accumulated usage events for aborted runs.
104    fn accounting(&self) -> Option<rhei_tui::AccountingRunSummary> {
105        self.inner
106            .lock()
107            .ok()
108            .and_then(|state| {
109                state
110                    .accounting
111                    .clone()
112                    .or_else(|| rhei_tui::summarize_usage_summaries(state.usages.iter()))
113            })
114    }
115}
116
117impl Default for SummarySink {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl rhei_tui::EventSink for SummarySink {
124    fn emit(&self, event: rhei_tui::RunEvent) {
125        let mut state = match self.inner.lock() {
126            Ok(state) => state,
127            Err(_) => return,
128        };
129        match event {
130            // `agent` is `Some` for agent-backed work, `None` for programs.
131            rhei_tui::RunEvent::SlotAssigned { slot, task, agent, .. } => {
132                let driver = if agent.is_some() { "agent" } else { "program" };
133                state.inflight.insert(slot, driver);
134                // A fresh attempt supersedes what the last one left unwritten.
135                // §FS-rhei-run-report.3.1
136                state.tasks.entry(task).or_default().missing_outputs = None;
137            }
138            rhei_tui::RunEvent::SlotReleased {
139                slot,
140                task,
141                from,
142                to,
143                log_path,
144                outcome,
145                exit_code,
146                duration_ms,
147                ..
148            } => {
149                let driver = state.inflight.remove(&slot).unwrap_or("program");
150                let entry = state.tasks.entry(task.clone()).or_default();
151                entry.driver = Some(driver);
152                entry.invocations += 1;
153                entry.last_duration_ms = duration_ms;
154                let outcome = match outcome {
155                    rhei_tui::TaskOutcome::Completed => LedgerOutcome::Completed,
156                    rhei_tui::TaskOutcome::Failed(msg) => LedgerOutcome::Failed(msg),
157                    rhei_tui::TaskOutcome::Cancelled => LedgerOutcome::Cancelled,
158                    rhei_tui::TaskOutcome::TimedOut => LedgerOutcome::TimedOut,
159                    rhei_tui::TaskOutcome::Interrupted => LedgerOutcome::Interrupted,
160                };
161                state.ledger.push(LedgerRecord {
162                    task,
163                    from,
164                    to,
165                    driver,
166                    log_path,
167                    exit_code,
168                    duration_ms,
169                    outcome,
170                });
171            }
172            rhei_tui::RunEvent::UsageReported { task, usage, .. } => {
173                state.usages.push(usage.clone());
174                state.usage_by_task.entry(task.clone()).or_default().push(usage);
175                let accounting = state
176                    .usage_by_task
177                    .get(&task)
178                    .and_then(|usages| rhei_tui::summarize_usage_summaries(usages.iter()));
179                if let Some(accounting) = accounting {
180                    state.tasks.entry(task).or_default().accounting = Some(accounting);
181                }
182            }
183            // The classification needs the names, not the sentence; a later
184            // invocation replaces the list, so it is the last attempt's, and it
185            // is kept with the state it belongs to. §FS-rhei-run-report.3.1
186            rhei_tui::RunEvent::TaskOutputsMissing { task, state: stalled_in, entries } => {
187                state.tasks.entry(task).or_default().missing_outputs =
188                    Some((stalled_in, entries));
189            }
190            rhei_tui::RunEvent::RunFinished { summary } => {
191                state.accounting = summary.accounting.clone().or_else(|| {
192                    rhei_tui::summarize_usage_summaries(state.usages.iter())
193                });
194            }
195            _ => {}
196        }
197    }
198}
199
200/// Build the report, write the durable Markdown files, then print the run's
201/// end-of-run surface: rich console summary on a TTY, else a `Report:` pointer.
202/// Best-effort — a load or write failure must not mask the result. §FS-rhei-run-report.1 §FS-rhei-run-report.3
203fn emit_run_report(
204    input: &std::path::Path,
205    machines: &rhei_validator::MachineSet,
206    summary: &SummarySink,
207    runtime_dir: &std::path::Path,
208    stats: RunStats,
209) {
210    use std::io::IsTerminal;
211    let Ok(loaded) = load_plan(input) else {
212        return;
213    };
214    // A dry run is a side-effect-free preview: render the console summary but
215    // never touch the durable report on disk. §FS-rhei-run-report.3.5
216    let dry_run = stats.dry_run;
217    // The commands the report suggests carry the plan, so they run from
218    // wherever the operator is reading it. §FS-rhei-errors.2
219    let plan_arg = plan_arg_for_help(input);
220    let mut report = RunSummaryReport::build(&loaded.rhei, machines, summary, stats, &plan_arg);
221    // Write the durable report even when stdout is piped, so CI runs leave the
222    // artifact; a dry run writes nothing, leaving `report_path` unset so no pointer
223    // prints below. §FS-rhei-run-report.1 §FS-rhei-run-report.3.5
224    if !dry_run {
225        if let Err(err) = report.write_to_runtime(runtime_dir) {
226            eprintln!("warning: could not write run report: {err}");
227        }
228    }
229    if std::io::stdout().is_terminal() {
230        // Honor NO_COLOR for users who disable ANSI globally.
231        let color = std::env::var_os("NO_COLOR").is_none();
232        print!("{}", report.render_tty(color));
233    } else if let Some(report_path) = &report.report_path {
234        // The pointer is for a person, so under `--json` it takes the channel
235        // people read; stdout is records to its last byte. §FS-rhei-run-json.1
236        if stdout_carries_json_records() {
237            eprintln!("Report: {report_path}");
238        } else {
239            println!("Report: {report_path}");
240        }
241    }
242}
243
244/// A short, stable run identifier derived from the run's wall-clock start. FNV-1a
245/// over the start nanoseconds folded to six hex digits — enough to disambiguate
246/// history entries without a random-number dependency. §FS-rhei-run-report.2
247fn short_run_id(started_at: std::time::SystemTime) -> String {
248    let nanos =
249        started_at.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
250    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
251    for b in nanos.to_le_bytes() {
252        hash ^= b as u64;
253        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
254    }
255    format!("{:06x}", hash & 0xff_ffff)
256}
257
258/// The relative path to the frozen dashboard artifact when one was written this
259/// run, for the report's Dashboard pointer. Gated on `enabled_this_run` so a
260/// stale `dashboard.html` left by an earlier run is never linked. §FS-rhei-run-report.2
261fn frozen_dashboard_relative_path(
262    enabled_this_run: bool,
263    runtime_dir: &std::path::Path,
264    workspace_root: &std::path::Path,
265) -> Option<String> {
266    if !enabled_this_run {
267        return None;
268    }
269    let path = runtime_dir.join("dashboard.html");
270    path.exists().then(|| relativize(&path, workspace_root))
271}
272
273/// The current process command line with `argv[0]` normalized to `rhei`, so the
274/// report header records the real flags the operator ran. §FS-rhei-run-report.2
275fn current_command_line() -> String {
276    let mut args: Vec<String> = std::env::args().collect();
277    if let Some(first) = args.first_mut() {
278        *first = "rhei".to_string();
279    }
280    args.join(" ")
281}
282
283/// Snapshot each task's normalized state at run start, keyed by task id, so the
284/// report can mark terminal-at-start tasks and reconcile callback advances that
285/// emit no slot events. §FS-rhei-run-report.8
286fn collect_initial_states(
287    rhei: &rhei_core::ast::Rhei,
288    machines: &rhei_validator::MachineSet,
289) -> HashMap<String, String> {
290    fn walk(
291        tasks: &[rhei_core::ast::Task],
292        machines: &rhei_validator::MachineSet,
293        out: &mut HashMap<String, String>,
294    ) {
295        for task in tasks {
296            out.insert(
297                task.id.to_string(),
298                normalized_state_name(task.state.as_str(), machines.for_task(&task.id)),
299            );
300            walk(&task.children, machines, out);
301        }
302    }
303    let mut out = HashMap::new();
304    walk(&rhei.tasks, machines, &mut out);
305    out
306}
307
308/// Writes a best-effort report if `rhei run` returns early with an error.
309/// Declared before the frontend so it drops after the terminal is restored; the
310/// happy path disarms it after the full report is written. §FS-rhei-run-report.1
311struct RunReportGuard<'a> {
312    input: &'a std::path::Path,
313    machines: &'a rhei_validator::MachineSet,
314    runtime_dir: std::path::PathBuf,
315    run_started: std::time::Instant,
316    run_started_wall: std::time::SystemTime,
317    run_id: String,
318    workspace_root: std::path::PathBuf,
319    command: String,
320    parallel: usize,
321    mode: &'static str,
322    initial_states: HashMap<String, String>,
323    /// A dry run is side-effect-free, so the fallback writes nothing on an early
324    /// error either. §FS-rhei-run-report.3.5
325    dry_run: bool,
326    /// Set once the frontend exists; without it there is nothing to report from.
327    summary: Option<std::sync::Arc<SummarySink>>,
328    /// Cleared by the happy path after the authoritative report is written.
329    armed: bool,
330}
331
332impl RunReportGuard<'_> {
333    /// The run wrote its own report; suppress the best-effort fallback.
334    fn disarm(&mut self) {
335        self.armed = false;
336    }
337}
338
339impl Drop for RunReportGuard<'_> {
340    fn drop(&mut self) {
341        // A dry run never writes a report, even when it aborts. §FS-rhei-run-report.3.5
342        if !self.armed || self.dry_run {
343            return;
344        }
345        let Some(summary) = self.summary.clone() else {
346            return;
347        };
348        // Best-effort from the data captured before the failure: spawn counts come
349        // from the ledger, callbacks and dashboard are unknown on an aborted run.
350        let ledger = summary.ledger();
351        let agents = ledger.iter().filter(|r| r.driver == "agent").count() as u32;
352        let programs = ledger.iter().filter(|r| r.driver == "program").count() as u32;
353        emit_run_report(
354            self.input,
355            self.machines,
356            &summary,
357            &self.runtime_dir,
358            RunStats {
359                agents_spawned: agents,
360                programs_spawned: programs,
361                callback_only: 0,
362                duration: Some(self.run_started.elapsed()),
363                dashboard: None,
364                run_id: self.run_id.clone(),
365                started_at: Some(self.run_started_wall),
366                workspace_root: self.workspace_root.clone(),
367                command: self.command.clone(),
368                parallel: self.parallel,
369                mode: self.mode,
370                initial_states: self.initial_states.clone(),
371                dry_run: false,
372                // The fallback fires while the run is failing, so there is no
373                // captured reading to use: ask the token now. §FS-rhei-run.3.2
374                interrupted: interrupted_by_signal(),
375            },
376        );
377    }
378}
379
380/// The scan glyph for a task's final state. Color and the state label remain the
381/// primary signal; the marker degrades to an ASCII fallback. §FS-rhei-run-report.3.2
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383enum Marker {
384    /// Terminal-success state.
385    Done,
386    /// Gating state awaiting a human.
387    Gate,
388    /// Blocked or failed — needs attention.
389    Attention,
390    /// Cancelled.
391    Cancelled,
392    /// Terminal at the start of the run — no work was attempted. §FS-rhei-run-report.3.2
393    TerminalAtStart,
394}
395
396impl Marker {
397    fn glyph(self) -> char {
398        match self {
399            Marker::Done => '✓',
400            Marker::Gate => '⏸',
401            Marker::Attention => '!',
402            Marker::Cancelled => '⊘',
403            Marker::TerminalAtStart => '·',
404        }
405    }
406
407    /// ANSI color for this marker class. Only `Attention` and `Gate` are
408    /// saturated; success, cancelled, and terminal-at-start rows stay calm.
409    /// §FS-rhei-viz-ux.3
410    fn color(self) -> &'static str {
411        match self {
412            Marker::Done => GREEN,
413            Marker::Gate => YELLOW,
414            Marker::Attention => RED,
415            Marker::Cancelled => DIM,
416            Marker::TerminalAtStart => DIM,
417        }
418    }
419
420    /// Whether this marker represents a task a human must still act on.
421    fn needs_attention(self) -> bool {
422        matches!(self, Marker::Gate | Marker::Attention)
423    }
424}
425
426/// State names that read as failure whatever the machine says about them.
427fn state_is_failure(state: &str) -> bool {
428    matches!(state, "blocked" | "failed")
429}
430
431/// Classify a state into a marker. Failure/cancel state names win over the
432/// `gating` flag: a machine may park a `blocked` task in a gating state, but it
433/// still reads as attention, not a calm gate. §FS-rhei-run-report.3.2
434fn classify_marker(state: &str, machine: &rhei_validator::StateMachine) -> Marker {
435    match state {
436        "cancelled" | "canceled" => return Marker::Cancelled,
437        _ if state_is_failure(state) => return Marker::Attention,
438        _ => {}
439    }
440    let def = machine.states.get(state);
441    if def.map(|d| d.gating).unwrap_or(false) {
442        Marker::Gate
443    } else if def.map(|d| d.terminal).unwrap_or(false) {
444        Marker::Done
445    } else {
446        Marker::Attention
447    }
448}
449
450/// The marker for one task row, with the run's own halt classification allowed
451/// to overrule the state-based reading.
452///
453/// A parent held open only by its own subtree is the eligibility rule working,
454/// not something wrong — and since every ancestor of one gated leaf is halted
455/// this way, classifying by state alone painted a whole spine of the tree red.
456/// It reads as a deliberate pause instead, exactly like the gate that is
457/// really holding it. A parent that is itself `blocked` or `failed` keeps its
458/// own attention marker: that is wrong independently of its children.
459// §FS-rhei-run-report.3.2
460fn marker_for_task(
461    id: &str,
462    state: &str,
463    machine: &rhei_validator::StateMachine,
464    halt_causes: &HashMap<String, HaltCause>,
465) -> Marker {
466    if is_calm_parent(id, state, machine, halt_causes) {
467        return Marker::Gate;
468    }
469    classify_marker(state, machine)
470}
471
472/// Whether this ticket is a parent held open *only* by its own subtree: the run
473/// classified it as waiting on descendants, and its own state neither reads as
474/// failure nor is pending a decision of its own.
475///
476/// Such a parent is not halted work — the open descendant is, and it reports
477/// for itself. It therefore takes no Attention row, no `N gated` tally, no
478/// `could not advance` count, and no blocked ledger entry; one gated leaf under
479/// three ancestors otherwise produced four of each, with the topmost parent's
480/// reason text repeating the whole transitive subtree. It keeps its calm marker
481/// and its `waiting on open descendant …` detail in the task tree, which is
482/// where the structure is worth showing.
483///
484/// A parent that is itself gating, `blocked`, or `failed` is excluded: those
485/// are things to act on independently of what its children are doing, even
486/// though the open subtree outranks them in the classification order.
487// §FS-rhei-run-report.3.1 §FS-rhei-run-report.3.2 §FS-rhei-plan-language.3
488fn is_calm_parent(
489    id: &str,
490    state: &str,
491    machine: &rhei_validator::StateMachine,
492    halt_causes: &HashMap<String, HaltCause>,
493) -> bool {
494    classify_marker(state, machine) == Marker::Attention
495        && !state_is_failure(state)
496        && matches!(halt_causes.get(id), Some(HaltCause::WaitingOnDescendants { .. }))
497}
498
499/// One row of the source-order task tree.
500struct TaskRow {
501    depth: usize,
502    id: String,
503    state: String,
504    marker: Marker,
505    /// Driver + timing for advanced tasks, or a short reason for halted ones.
506    detail: Option<String>,
507}
508
509/// A halted task surfaced in the Attention group, with its proven blocker and
510/// the next action. §FS-rhei-run-report.3.1
511struct AttentionRow {
512    id: String,
513    state: String,
514    reason: String,
515    next: String,
516    /// True for a deliberate pause — a gating state awaiting a human, or a
517    /// parent held open by its own subtree; false for a blocked/failed task.
518    /// It splits the `N gated · M blocked` header. §FS-rhei-run-report.3.1
519    is_gate: bool,
520}
521
522/// Run-level facts the summary needs beyond the plan itself. §FS-rhei-run-report.8
523pub struct RunStats {
524    pub agents_spawned: u32,
525    pub programs_spawned: u32,
526    pub callback_only: u32,
527    pub duration: Option<std::time::Duration>,
528    pub dashboard: Option<String>,
529    /// Short run identifier shown in the header and history filename.
530    pub run_id: String,
531    /// Wall-clock start, rendered in the report header. `None` falls back to
532    /// the run id alone.
533    pub started_at: Option<std::time::SystemTime>,
534    /// Workspace root, used to render relative artifact links. §FS-rhei-run-report.1
535    pub workspace_root: std::path::PathBuf,
536    /// The command label shown in the header (`rhei run …`).
537    pub command: String,
538    /// Worker parallelism for the run.
539    pub parallel: usize,
540    /// `"agent"` or `"callback"` execution mode.
541    pub mode: &'static str,
542    /// Task id → normalized state at run start, for terminal-at-start detection
543    /// and reconciling callback advances that emit no slot events.
544    /// §FS-rhei-run-report.8
545    pub initial_states: HashMap<String, String>,
546    /// True under `--dry-run`: the report records a simulated run that applied no
547    /// changes, so its result line and counts read as a preview. §FS-rhei-run-report.3.5
548    pub dry_run: bool,
549    /// True when the run's own loop was cut short by a signal, captured where
550    /// that loop ends: a run already finished when the signal arrived — parked
551    /// on the TUI's finished screen — has a result of its own to report.
552    // §FS-rhei-run.3.2
553    pub interrupted: bool,
554}
555
556/// One rendered Transition Ledger row. §FS-rhei-run-report.4
557struct LedgerEntry {
558    task: String,
559    from: String,
560    /// Destination state, or `-` when no transition was taken.
561    to: String,
562    /// `agent`, `program`, `callback-only`, `terminal-at-start`, or `blocked`.
563    driver: &'static str,
564    /// Invocation label + relative log link, or `none`.
565    invocation: String,
566    reason: String,
567}
568
569/// One spawned agent/program for the Invocations section. §FS-rhei-run-report.7
570struct InvocationRow {
571    driver: &'static str,
572    task: String,
573    /// `exit 0`, `exit 42`, `cancelled`, `timed out`, or `—`.
574    exit: String,
575    duration_ms: u64,
576    /// Relative log path.
577    log: String,
578}
579
580/// Direct accounting shown for a task in the end-of-run report.
581struct TaskAccountingRow {
582    task: String,
583    cost: String,
584    total: String,
585    input: String,
586    input_cached: String,
587    output: String,
588    output_cached: String,
589    coverage: String,
590}
591
592/// The fully resolved run report, ready to render to the console or to Markdown.
593pub struct RunSummaryReport {
594    title: String,
595    result: String,
596    duration: Option<std::time::Duration>,
597    /// State label, count, and marker class, in canonical count order.
598    state_counts: Vec<(String, usize, Marker)>,
599    total_tasks: usize,
600    work: String,
601    accounting: Option<rhei_tui::AccountingRunSummary>,
602    attention: Vec<AttentionRow>,
603    rows: Vec<TaskRow>,
604    dashboard: Option<String>,
605    // ── Durable-report fields (§FS-rhei-run-report.1, .2, .4, .7) ────────────
606    run_id: String,
607    started_at: Option<std::time::SystemTime>,
608    workspace: String,
609    command: String,
610    parallel: usize,
611    mode: &'static str,
612    agents_spawned: u32,
613    programs_spawned: u32,
614    callback_only: u32,
615    terminal_at_start: usize,
616    ledger: Vec<LedgerEntry>,
617    invocations: Vec<InvocationRow>,
618    task_accounting: Vec<TaskAccountingRow>,
619    /// Relative paths to the written report files, filled by [`write_to_runtime`].
620    report_path: Option<String>,
621    history_path: Option<String>,
622}
623
624// ANSI codes; emitted only when color is enabled.
625const RESET: &str = "\x1b[0m";
626const BOLD: &str = "\x1b[1m";
627const DIM: &str = "\x1b[2m";
628const RED: &str = "\x1b[31m";
629const GREEN: &str = "\x1b[32m";
630const YELLOW: &str = "\x1b[33m";
631
632/// Width of the static state-distribution bar, in cells.
633const BAR_WIDTH: usize = 24;
634/// Maximum task rows printed before fully-completed subtrees collapse.
635const MAX_TASK_ROWS: usize = 40;
636/// Maximum attention rows printed before the rest defer to the report.
637const MAX_ATTENTION_ROWS: usize = 5;
638
639impl RunSummaryReport {
640    /// Build the report from the on-disk plan, the run's spawn counts, and the
641    /// per-task activity captured by [`SummarySink`]. §FS-rhei-run-report.8
642    pub fn build(
643        rhei: &rhei_core::ast::Rhei,
644        machines: &rhei_validator::MachineSet,
645        summary: &SummarySink,
646        stats: RunStats,
647        plan_arg: &str,
648    ) -> Self {
649        let activity = summary.snapshot();
650        // Read once: the ledger answers both "why is this ticket halted" below
651        // and the report's own Transition Ledger further down.
652        let ledger_records = summary.ledger();
653        let ledger = &ledger_records;
654
655        // Why each halted ticket is halted, resolved once against the whole
656        // plan. The table below needs the plan's priors and claims, which a
657        // per-task walk cannot see. §FS-rhei-run-report.3.1
658        let halt_causes: HashMap<String, HaltCause> = classify_halted_tasks(
659            rhei,
660            machines,
661            &None,
662            &|id| activity.contains_key(id),
663            // §FS-rhei-run-report.3.1: what the ticket's last exit-0 worker
664            // left unwritten, captured live rather than re-read from prose, and
665            // only while the ticket still sits in the state it stalled in.
666            &|id, state| {
667                activity
668                    .get(id)
669                    .and_then(|entry| entry.missing_outputs.as_ref())
670                    .filter(|(stalled_in, entries)| stalled_in == state && !entries.is_empty())
671                    .map(|(_, entries)| entries.clone())
672            },
673            // Only the ticket's *last* invocation explains where it is, and
674            // only for a run the operator stopped: a failing run ends its
675            // workers the same way. §FS-rhei-run-report.3.1 §FS-rhei-run.3.2
676            &|id| {
677                stats.interrupted
678                    && matches!(
679                        ledger
680                            .iter()
681                            .rev()
682                            .find(|record| record.task == id)
683                            .map(|record| &record.outcome),
684                        Some(LedgerOutcome::Interrupted)
685                    )
686            },
687            plan_arg,
688        )
689        .into_iter()
690        .map(|(task, cause)| (task.id.to_string(), cause))
691        .collect();
692
693        // Source-order walk that preserves hierarchy depth.
694        let mut rows = Vec::new();
695        let mut attention = Vec::new();
696        let mut counts: std::collections::BTreeMap<String, (usize, Marker)> =
697            std::collections::BTreeMap::new();
698        collect_rows(
699            &rhei.tasks,
700            0,
701            machines,
702            &activity,
703            &halt_causes,
704            &mut rows,
705            &mut attention,
706            &mut counts,
707        );
708
709        // Terminal-at-start: same terminal state at run start as now, so no work
710        // was attempted. The row keeps its state count but flips to the calm `·`
711        // marker so it reads apart from work that just ran. §FS-rhei-run-report.3.2
712        let mut terminal_at_start = 0usize;
713        for row in &mut rows {
714            let was = stats.initial_states.get(&row.id).map(String::as_str);
715            let unchanged_terminal = was == Some(row.state.as_str())
716                && is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id)));
717            if unchanged_terminal {
718                terminal_at_start += 1;
719                // A success state flips to the calm `·` marker; a cancelled task
720                // keeps its own `⊘` marker but still counts as terminal-at-start.
721                if row.marker == Marker::Done {
722                    row.marker = Marker::TerminalAtStart;
723                    row.detail = Some("terminal at start".to_string());
724                }
725            }
726        }
727
728        let total_tasks = rows.len();
729
730        // Counts in canonical order: success, gate, attention, cancelled.
731        let mut state_counts: Vec<(String, usize, Marker)> =
732            counts.into_iter().map(|(state, (n, marker))| (state, n, marker)).collect();
733        state_counts.sort_by_key(|(_, _, marker)| marker_order(*marker));
734
735        let no_work = stats.agents_spawned == 0 && stats.programs_spawned == 0;
736        let advanced_without_work = rows.iter().any(|r| {
737            r.marker == Marker::Done
738                && stats.initial_states.get(&r.id).map(String::as_str) != Some(r.state.as_str())
739        });
740        // A dry run simulated transitions but applied nothing, so its result
741        // reads as a preview rather than an outcome. §FS-rhei-run-report.3.5
742        let result = if stats.dry_run {
743            "dry run — no changes applied".to_string()
744        } else {
745            // Why the loop ended, as the caller read it when it ended (see
746            // `result_phrase`). §FS-rhei-run.3.2 §FS-rhei-run-report.3.1
747            result_phrase(&attention, &rows, no_work, advanced_without_work, stats.interrupted)
748        };
749        let work = format_work(stats.agents_spawned, stats.programs_spawned, stats.callback_only);
750        let accounting = summary.accounting();
751        let task_accounting = build_task_accounting_rows(&rows, &activity);
752
753        let ledger_rows = build_ledger(
754            &rows,
755            &attention,
756            &halt_causes,
757            ledger,
758            &stats.initial_states,
759            machines,
760            &stats.workspace_root,
761        );
762        let invocations = build_invocations(ledger, &stats.workspace_root);
763
764        Self {
765            title: rhei.title.clone(),
766            result,
767            duration: stats.duration,
768            state_counts,
769            total_tasks,
770            work,
771            accounting,
772            attention,
773            rows,
774            dashboard: stats.dashboard,
775            run_id: stats.run_id,
776            started_at: stats.started_at,
777            workspace: stats.workspace_root.display().to_string(),
778            command: stats.command,
779            parallel: stats.parallel,
780            mode: stats.mode,
781            agents_spawned: stats.agents_spawned,
782            programs_spawned: stats.programs_spawned,
783            callback_only: stats.callback_only,
784            terminal_at_start,
785            ledger: ledger_rows,
786            invocations,
787            task_accounting,
788            report_path: None,
789            history_path: None,
790        }
791    }
792
793    /// Render the rich, colored summary for an interactive terminal.
794    /// §FS-rhei-run-report.3.1
795    pub fn render_tty(&self, color: bool) -> String {
796        let c = Palette::new(color);
797        let mut out = String::new();
798
799        // Header: title + duration, then the result line.
800        let dur = self.duration.map(format_duration_long).unwrap_or_default();
801        out.push_str(&format!(
802            "\n{}Run Report{}  {}{}{}",
803            c.bold, c.reset, c.bold, self.title, c.reset
804        ));
805        if !dur.is_empty() {
806            out.push_str(&format!("   {}{}{}", c.dim, dur, c.reset));
807        }
808        out.push('\n');
809        out.push_str(&format!("  {}{}{}\n\n", c.result_color(&self.result), self.result, c.reset));
810
811        // Counts: distribution bar + labeled states, then work.
812        out.push_str("  States    ");
813        out.push_str(&self.render_bar(&c));
814        out.push_str("   ");
815        out.push_str(&self.render_state_labels(&c));
816        out.push('\n');
817        out.push_str(&format!("  Work      {}\n", self.work));
818        if let Some(accounting) = &self.accounting {
819            // §FS-rhei-cost-accounting.9: End-of-run surfaces show separate input,
820            // cached input, output, and cached output totals.
821            out.push_str(&format!(
822                "  Cost      {} · Total {} · In {} · In cached {} · Out {} · Out cached {} · Coverage {:?}\n",
823                format_summary_cost(accounting),
824                format_dimension_value(&accounting.total),
825                format_dimension_value(&accounting.input_total),
826                format_dimension_value(&accounting.input_cached_read),
827                format_dimension_value(&accounting.output_total),
828                format_dimension_value(&accounting.output_cached_read),
829                accounting.coverage,
830            ));
831        }
832
833        // Attention.
834        if !self.attention.is_empty() {
835            let gated = self.attention.iter().filter(|a| a.is_gate).count();
836            let blocked = self.attention.len() - gated;
837            out.push_str(&format!(
838                "\n{}Attention{}  {} gated · {} blocked\n",
839                c.bold, c.reset, gated, blocked
840            ));
841            for row in self.attention.iter().take(MAX_ATTENTION_ROWS) {
842                out.push_str(&format!(
843                    "  {}!{} {:<26} {}{:<11}{} {}\n",
844                    c.red, c.reset, row.id, c.dim, row.state, c.reset, row.reason
845                ));
846                out.push_str(&format!("        {}→ {}{}\n", c.dim, row.next, c.reset));
847            }
848            if self.attention.len() > MAX_ATTENTION_ROWS {
849                out.push_str(&format!(
850                    "  {}… {} more in the report{}\n",
851                    c.dim,
852                    self.attention.len() - MAX_ATTENTION_ROWS,
853                    c.reset
854                ));
855            }
856        }
857
858        // Task tree.
859        out.push_str(&format!(
860            "\n{}Tasks{}   {} tasks · source order\n",
861            c.bold, c.reset, self.total_tasks
862        ));
863        out.push_str(&self.render_tree(&c));
864
865        // Pointers: the durable report is the at-a-glance summary's companion;
866        // the console points at it for the full forensic read. §FS-rhei-run-report.3.1
867        out.push('\n');
868        if let Some(report) = &self.report_path {
869            out.push_str(&format!("Report     {report}\n"));
870        }
871        if let Some(history) = &self.history_path {
872            out.push_str(&format!("History    {history}\n"));
873        }
874        if let Some(dashboard) = &self.dashboard {
875            out.push_str(&format!("Dashboard  {dashboard}\n"));
876        }
877        // Drop trailing spaces left by empty detail columns; keep the final newline.
878        let trailing_newline = out.ends_with('\n');
879        let mut trimmed = out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
880        if trailing_newline {
881            trimmed.push('\n');
882        }
883        trimmed
884    }
885
886    /// Render the durable Markdown report — header, outcome strip, attention,
887    /// ledger, task final states, invocations: the commit-friendly explanation
888    /// an operator can read without the dashboard. §FS-rhei-run-report.1 §FS-rhei-run-report.2
889    pub fn render_markdown(&self) -> String {
890        let mut out = String::new();
891
892        // 1. Header.
893        out.push_str(&format!("# Run Report: {}\n\n", self.title));
894        let when = self
895            .started_at
896            .map(format_iso8601_utc)
897            .map(|ts| format!("{ts} / {}", self.run_id))
898            .unwrap_or_else(|| self.run_id.clone());
899        out.push_str(&format!("Run: {when}\n"));
900        out.push_str(&format!("Workspace: {}\n", self.workspace));
901        out.push_str(&format!("Command: {}\n", self.command));
902        out.push_str(&format!("Mode: {} · parallel {}\n", self.mode, self.parallel));
903        if let Some(dur) = self.duration {
904            out.push_str(&format!("Duration: {}\n", format_duration_long(dur)));
905        }
906        out.push_str(&format!("Result: {}\n", self.result));
907        if let Some(dashboard) = &self.dashboard {
908            out.push_str(&format!("Dashboard: {dashboard}\n"));
909        }
910        out.push('\n');
911
912        // 2. Outcome strip — final states and run activity. The reuse/blocked
913        // signal sits at the top of the report, never below a fold.
914        out.push_str("| Final states | Count |\n| --- | ---: |\n");
915        for (state, n, _) in &self.state_counts {
916            out.push_str(&format!("| {state} | {n} |\n"));
917        }
918        out.push('\n');
919        let could_not_advance = self.attention.len();
920        out.push_str("| Activity | Count |\n| --- | ---: |\n");
921        out.push_str(&format!("| agent invocations | {} |\n", self.agents_spawned));
922        out.push_str(&format!("| program invocations | {} |\n", self.programs_spawned));
923        out.push_str(&format!("| callback-only transitions | {} |\n", self.callback_only));
924        out.push_str(&format!("| terminal at start | {} |\n", self.terminal_at_start));
925        out.push_str(&format!("| could not advance | {could_not_advance} |\n"));
926        out.push('\n');
927        if let Some(accounting) = &self.accounting {
928            // §FS-rhei-cost-accounting.9: Durable reports carry the run accounting strip.
929            out.push_str("| Accounting | Value |\n| --- | ---: |\n");
930            out.push_str(&format!("| cost | {} |\n", format_summary_cost(accounting)));
931            out.push_str(&format!(
932                "| total tokens | {} |\n",
933                format_dimension_value(&accounting.total)
934            ));
935            out.push_str(&format!(
936                "| input tokens | {} |\n",
937                format_dimension_value(&accounting.input_total)
938            ));
939            out.push_str(&format!(
940                "| input cached | {} |\n",
941                format_dimension_value(&accounting.input_cached_read)
942            ));
943            out.push_str(&format!(
944                "| output tokens | {} |\n",
945                format_dimension_value(&accounting.output_total)
946            ));
947            out.push_str(&format!(
948                "| output cached | {} |\n",
949                format_dimension_value(&accounting.output_cached_read)
950            ));
951            out.push_str(&format!("| coverage | {:?} |\n", accounting.coverage));
952            out.push('\n');
953        }
954        if self.agents_spawned == 0 && self.programs_spawned == 0 {
955            out.push_str(
956                "> No agent or program ran this run. Any task that advanced did so through \
957                 callbacks, transition rules, or outputs that already existed — inspect the \
958                 ledger below before assuming work was performed.\n\n",
959            );
960        }
961
962        // 3. Attention.
963        if !self.attention.is_empty() {
964            out.push_str("## Attention\n\n");
965            out.push_str("| Task | State | Reason | Next action |\n| --- | --- | --- | --- |\n");
966            for a in &self.attention {
967                out.push_str(&format!(
968                    "| {} | {} | {} | {} |\n",
969                    md_cell(&a.id),
970                    md_cell(&a.state),
971                    md_cell(&a.reason),
972                    md_cell(&a.next),
973                ));
974            }
975            out.push('\n');
976        }
977
978        // 4. Transition ledger.
979        out.push_str("## Transition Ledger\n\n");
980        out.push_str(
981            "| Task | From | To | Driver | Invocation | Reason |\n\
982             | --- | --- | --- | --- | --- | --- |\n",
983        );
984        for e in &self.ledger {
985            out.push_str(&format!(
986                "| {} | {} | {} | {} | {} | {} |\n",
987                e.task,
988                md_cell(&e.from),
989                md_cell(&e.to),
990                e.driver,
991                md_link_or_text(&e.invocation),
992                md_cell(&e.reason),
993            ));
994        }
995        out.push('\n');
996
997        // 5. Task final states.
998        out.push_str("## Task Final States\n\n");
999        for row in &self.rows {
1000            let indent = "  ".repeat(row.depth);
1001            let detail = row.detail.as_deref().unwrap_or("");
1002            let detail = if detail.is_empty() {
1003                String::new()
1004            } else {
1005                format!(" — {detail}")
1006            };
1007            out.push_str(&format!(
1008                "{indent}- {} `{}` ({}){detail}\n",
1009                row.marker.glyph(),
1010                row.id,
1011                row.state,
1012            ));
1013        }
1014        out.push('\n');
1015
1016        if !self.task_accounting.is_empty() {
1017            out.push_str("## Task Costs\n\n");
1018            out.push_str(
1019                "| Task | Cost | Total | Input | Input cached | Output | Output cached | Coverage |\n\
1020                 | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n",
1021            );
1022            for row in &self.task_accounting {
1023                out.push_str(&format!(
1024                    "| {} | {} | {} | {} | {} | {} | {} | {} |\n",
1025                    md_cell(&row.task),
1026                    row.cost,
1027                    row.total,
1028                    row.input,
1029                    row.input_cached,
1030                    row.output,
1031                    row.output_cached,
1032                    row.coverage,
1033                ));
1034            }
1035            out.push('\n');
1036        }
1037
1038        // 6. Invocations.
1039        if !self.invocations.is_empty() {
1040            out.push_str("## Invocations\n\n");
1041            out.push_str(
1042                "| Task | Driver | Exit | Duration | Log |\n| --- | --- | --- | --- | --- |\n",
1043            );
1044            for inv in &self.invocations {
1045                out.push_str(&format!(
1046                    "| {} | {} | {} | {} | [{}]({}) |\n",
1047                    inv.task,
1048                    inv.driver,
1049                    inv.exit,
1050                    format_duration_short(inv.duration_ms),
1051                    inv.log,
1052                    inv.log,
1053                ));
1054            }
1055            out.push('\n');
1056        }
1057
1058        out
1059    }
1060
1061    /// Write the durable report to `runtime/run-report.md` and a timestamped
1062    /// history entry, recording the relative paths for the console pointer.
1063    /// Best-effort. §FS-rhei-run-report.1
1064    pub fn write_to_runtime(&mut self, runtime_dir: &std::path::Path) -> std::io::Result<()> {
1065        let body = self.render_markdown();
1066        let latest = runtime_dir.join("run-report.md");
1067        let history_dir = runtime_dir.join("run-reports");
1068        std::fs::create_dir_all(&history_dir)?;
1069        let stamp = self
1070            .started_at
1071            .map(format_iso8601_utc)
1072            .map(|ts| ts.replace(':', "-"))
1073            .unwrap_or_else(|| "unknown".to_string());
1074        let history = history_dir.join(format!("{stamp}-{}.md", self.run_id));
1075        std::fs::write(&latest, &body)?;
1076        std::fs::write(&history, &body)?;
1077        self.report_path = Some(relativize(&latest, &self.workspace_root_path()));
1078        self.history_path = Some(relativize(&history, &self.workspace_root_path()));
1079        Ok(())
1080    }
1081
1082    /// The workspace root reconstructed from its display string, for link bases.
1083    fn workspace_root_path(&self) -> std::path::PathBuf {
1084        std::path::PathBuf::from(&self.workspace)
1085    }
1086
1087    /// The static state-distribution bar, sized by count and colored by class.
1088    /// Drawn once; never animates. §FS-rhei-run-report.3.1 §FS-rhei-viz-ux.4
1089    fn render_bar(&self, c: &Palette) -> String {
1090        if self.total_tasks == 0 {
1091            return String::new();
1092        }
1093        // Proportional widths, with at least one cell per non-empty state.
1094        let mut widths: Vec<usize> = self
1095            .state_counts
1096            .iter()
1097            .map(|(_, n, _)| {
1098                let w = (*n * BAR_WIDTH) / self.total_tasks;
1099                if *n > 0 {
1100                    w.max(1)
1101                } else {
1102                    0
1103                }
1104            })
1105            .collect();
1106        // Trim overflow from the largest segment so total == BAR_WIDTH.
1107        let mut total: usize = widths.iter().sum();
1108        while total > BAR_WIDTH {
1109            if let Some((idx, _)) =
1110                widths.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w)
1111            {
1112                widths[idx] -= 1;
1113                total -= 1;
1114            } else {
1115                break;
1116            }
1117        }
1118        let mut bar = String::new();
1119        for ((_, _, marker), w) in self.state_counts.iter().zip(widths) {
1120            if w == 0 {
1121                continue;
1122            }
1123            bar.push_str(c.color(marker.color()));
1124            bar.push_str(&"█".repeat(w));
1125            bar.push_str(c.reset);
1126        }
1127        bar
1128    }
1129
1130    fn render_state_labels(&self, c: &Palette) -> String {
1131        self.state_counts
1132            .iter()
1133            .map(|(state, n, marker)| {
1134                format!("{}{} {}{}", c.color(marker.color()), n, state, c.reset)
1135            })
1136            .collect::<Vec<_>>()
1137            .join(" · ")
1138    }
1139
1140    fn render_tree(&self, c: &Palette) -> String {
1141        let mut out = String::new();
1142        let mut collapsed = 0usize;
1143        let mut shown = 0usize;
1144        for row in &self.rows {
1145            // Collapse calm completed leaf rows once the tree grows long, but
1146            // never hide anything that needs a human. §FS-rhei-run-report.3.2
1147            if shown >= MAX_TASK_ROWS && row.marker == Marker::Done {
1148                collapsed += 1;
1149                continue;
1150            }
1151            shown += 1;
1152            let gutter = if row.depth > 0 { "│ ".repeat(row.depth) } else { String::new() };
1153            let detail = row.detail.as_deref().unwrap_or("");
1154            // Pad the state column *outside* the color codes so that empty-detail
1155            // rows can have their trailing padding trimmed away.
1156            let state_cell = c.colored(row.marker.color(), &row.state);
1157            let state_pad = " ".repeat(11usize.saturating_sub(row.state.chars().count()));
1158            out.push_str(&format!(
1159                "  {}{}{}{} {:<width$} {}{} {}\n",
1160                c.dim,
1161                gutter,
1162                c.reset,
1163                c.colored(row.marker.color(), &row.marker.glyph().to_string()),
1164                row.id,
1165                state_cell,
1166                state_pad,
1167                detail,
1168                width = 26usize.saturating_sub(row.depth * 2),
1169            ));
1170        }
1171        if collapsed > 0 {
1172            out.push_str(&format!(
1173                "  {}… {collapsed} completed tasks collapsed{}\n",
1174                c.dim, c.reset
1175            ));
1176        }
1177        out
1178    }
1179}
1180
1181/// Recursive source-order walk capturing depth, markers, detail, counts, and
1182/// the attention list.
1183#[allow(clippy::too_many_arguments)]
1184fn collect_rows(
1185    tasks: &[rhei_core::ast::Task],
1186    depth: usize,
1187    machines: &rhei_validator::MachineSet,
1188    activity: &HashMap<String, TaskActivity>,
1189    halt_causes: &HashMap<String, HaltCause>,
1190    rows: &mut Vec<TaskRow>,
1191    attention: &mut Vec<AttentionRow>,
1192    counts: &mut std::collections::BTreeMap<String, (usize, Marker)>,
1193) {
1194    for task in tasks {
1195        let machine = machines.for_task(&task.id);
1196        let state = normalized_state_name(task.state.as_str(), machine);
1197        let id = task.id.to_string();
1198        let marker = marker_for_task(&id, &state, machine, halt_causes);
1199
1200        let entry = counts.entry(state.clone()).or_insert((0, marker));
1201        entry.0 += 1;
1202
1203        let detail = task_detail(&id, &state, marker, halt_causes, activity);
1204        // §FS-rhei-run-report.3.1: a parent held open by its own subtree is not
1205        // halted work, so it is counted nowhere the descendant is already
1206        // counted — see [`is_calm_parent`].
1207        if marker.needs_attention() && !is_calm_parent(&id, &state, machine, halt_causes) {
1208            let (reason, next) = attention_reason(marker, &id, &state, halt_causes);
1209            attention.push(AttentionRow {
1210                id: id.clone(),
1211                state: state.clone(),
1212                reason,
1213                next,
1214                is_gate: marker == Marker::Gate,
1215            });
1216        }
1217
1218        rows.push(TaskRow { depth, id, state, marker, detail });
1219        collect_rows(
1220            &task.children,
1221            depth + 1,
1222            machines,
1223            activity,
1224            halt_causes,
1225            rows,
1226            attention,
1227            counts,
1228        );
1229    }
1230}
1231
1232/// Build the detail column for a task row: driver + timing when the run spawned
1233/// work, otherwise a short reason for halted tasks. §FS-rhei-run-report.3.2
1234fn task_detail(
1235    id: &str,
1236    state: &str,
1237    marker: Marker,
1238    halt_causes: &HashMap<String, HaltCause>,
1239    activity: &HashMap<String, TaskActivity>,
1240) -> Option<String> {
1241    if let Some(act) = activity.get(id) {
1242        let cost = act
1243            .accounting
1244            .as_ref()
1245            .map(|accounting| format!(" · {}", format_summary_cost(accounting)))
1246            .unwrap_or_default();
1247        if let Some(driver) = act.driver {
1248            let label = if act.invocations > 1 {
1249                format!("{driver}×{}", act.invocations)
1250            } else {
1251                driver.to_string()
1252            };
1253            return Some(format!(
1254                "{label}  {}{}",
1255                format_duration_short(act.last_duration_ms),
1256                cost
1257            ));
1258        }
1259        if !cost.is_empty() {
1260            return Some(cost.trim_start_matches(" · ").to_string());
1261        }
1262    }
1263    match marker {
1264        Marker::Gate | Marker::Attention => {
1265            Some(attention_reason(marker, id, state, halt_causes).0)
1266        }
1267        _ => None,
1268    }
1269}
1270
1271fn build_task_accounting_rows(
1272    rows: &[TaskRow],
1273    activity: &HashMap<String, TaskActivity>,
1274) -> Vec<TaskAccountingRow> {
1275    rows.iter()
1276        .filter_map(|row| {
1277            let accounting = activity.get(&row.id)?.accounting.as_ref()?;
1278            Some(TaskAccountingRow {
1279                task: row.id.clone(),
1280                cost: format_summary_cost(accounting),
1281                total: format_dimension_value(&accounting.total),
1282                input: format_dimension_value(&accounting.input_total),
1283                input_cached: format_dimension_value(&accounting.input_cached_read),
1284                output: format_dimension_value(&accounting.output_total),
1285                output_cached: format_dimension_value(&accounting.output_cached_read),
1286                coverage: format!("{:?}", accounting.coverage),
1287            })
1288        })
1289        .collect()
1290}
1291
1292/// The reason and next action for a halted task.
1293///
1294/// The plan-wide classification knows whether the
1295/// ticket is claimed, waiting on a prior, or manual-only, and names the command
1296/// that clears each. Reporting all three as "stalled in non-terminal state <s>"
1297/// and advising "inspect logs or mark the task cancelled" told an operator to
1298/// cancel work that only needed a claim released, and pointed at logs a run
1299/// that spawned nothing never wrote. The generic pair remains the fallback for
1300/// a ticket the classifier does not reach.
1301// §FS-rhei-run-report.3.1
1302fn attention_reason(
1303    marker: Marker,
1304    id: &str,
1305    state: &str,
1306    halt_causes: &HashMap<String, HaltCause>,
1307) -> (String, String) {
1308    if let Some(cause) = halt_causes.get(id) {
1309        return cause.describe(id, state);
1310    }
1311    match marker {
1312        Marker::Gate => HaltCause::Gate.describe(id, state),
1313        _ => HaltCause::Stalled.describe(id, state),
1314    }
1315}
1316
1317/// The run's one-line outcome.
1318///
1319/// `interrupted` outranks everything else: the operator stopped the run, so
1320/// whatever the plan looks like now is a snapshot of work in progress and not a
1321/// verdict on it. Reading it as "stopped for human attention" told the operator
1322/// to go and act on tickets whose only problem was that they were interrupted.
1323///
1324/// The caller passes the *signal* reading of the stop token, not the bare one:
1325/// a run unwinding from an error raises it too, on its way to tearing down the
1326/// groups it still owned, and that run has a verdict of its own. It passes the
1327/// reading taken where its loop ended, not one taken here: a run that had
1328/// already finished when the signal arrived was not cut short by it.
1329// §FS-rhei-run-report.3.1 §FS-rhei-run.3.2
1330fn result_phrase(
1331    attention: &[AttentionRow],
1332    rows: &[TaskRow],
1333    no_work: bool,
1334    advanced_without_work: bool,
1335    // Named for the reading, not for the function that takes it: spelling this
1336    // `interrupted_by_signal` put the free function of that name in scope
1337    // beside a parameter shadowing it, and made "ask the token here" — the one
1338    // thing the paragraph above forbids — a one-character edit that compiles.
1339    cut_short_by_signal: bool,
1340) -> String {
1341    let all_terminal_success =
1342        rows.iter().all(|r| matches!(r.marker, Marker::Done | Marker::TerminalAtStart));
1343    if cut_short_by_signal {
1344        "interrupted — re-run to continue".to_string()
1345    } else if !attention.is_empty() {
1346        // Gated and blocked tasks both halt the run for a human; the report and
1347        // tree carry the per-task distinction. §FS-rhei-run-report.6
1348        "stopped for human attention".to_string()
1349    } else if all_terminal_success && no_work && advanced_without_work {
1350        // A run that advanced tasks while spawning nothing must not read like a
1351        // fast successful run — name the absence of work. §FS-rhei-run-report.3.3
1352        "completed — no work spawned".to_string()
1353    } else if all_terminal_success {
1354        "completed".to_string()
1355    } else {
1356        "finished".to_string()
1357    }
1358}
1359
1360/// Escape a value for a Markdown table cell: pipes would split the column and
1361/// newlines would break the row, so both are neutralized.
1362fn md_cell(value: &str) -> String {
1363    value.replace('|', "\\|").replace('\n', " ")
1364}
1365
1366/// Render an invocation cell. `"<driver> / <log>"` becomes `<driver> / [log](log)`
1367/// so the log is a relative link; anything else (notably `none`) is escaped text.
1368/// §FS-rhei-run-report.7
1369fn md_link_or_text(value: &str) -> String {
1370    match value.split_once(" / ") {
1371        Some((label, path)) => format!("{} / [{}]({})", md_cell(label), path, path),
1372        None => md_cell(value),
1373    }
1374}
1375
1376/// Render a path relative to the workspace root with forward slashes, so report
1377/// links survive the workspace being moved, committed, or pasted into an issue.
1378/// §FS-rhei-run-report.1
1379fn relativize(path: &std::path::Path, root: &std::path::Path) -> String {
1380    let rel = path.strip_prefix(root).unwrap_or(path);
1381    rel.components()
1382        .map(|c| c.as_os_str().to_string_lossy())
1383        .collect::<Vec<_>>()
1384        .join("/")
1385}
1386
1387/// A short reason string for a spawned invocation, from its outcome and exit.
1388fn ledger_outcome_reason(outcome: &LedgerOutcome, exit_code: Option<i32>) -> String {
1389    match outcome {
1390        LedgerOutcome::Completed => match exit_code {
1391            Some(0) | None => "exit 0".to_string(),
1392            Some(code) => format!("exit {code}"),
1393        },
1394        LedgerOutcome::Failed(msg) => {
1395            let msg = msg.lines().next().unwrap_or("").trim();
1396            match exit_code {
1397                Some(code) if msg.is_empty() => format!("failed, exit {code}"),
1398                Some(code) => format!("exit {code}: {msg}"),
1399                None if msg.is_empty() => "failed".to_string(),
1400                None => format!("failed: {msg}"),
1401            }
1402        }
1403        LedgerOutcome::Cancelled => "cancelled".to_string(),
1404        LedgerOutcome::TimedOut => "timed out".to_string(),
1405        // Not a verdict on the ticket: the run stopped the worker. §FS-rhei-run.3.2
1406        LedgerOutcome::Interrupted => "interrupted".to_string(),
1407    }
1408}
1409
1410/// Assemble the Transition Ledger in source order: spawned rows from the event
1411/// stream, plus synthesized callback / terminal-at-start / blocked rows for tasks
1412/// that emit no slot events. §FS-rhei-run-report.4
1413#[allow(clippy::too_many_arguments)]
1414fn build_ledger(
1415    rows: &[TaskRow],
1416    attention: &[AttentionRow],
1417    halt_causes: &HashMap<String, HaltCause>,
1418    records: &[LedgerRecord],
1419    initial_states: &HashMap<String, String>,
1420    machines: &rhei_validator::MachineSet,
1421    workspace_root: &std::path::Path,
1422) -> Vec<LedgerEntry> {
1423    let attention_by_id: HashMap<&str, &AttentionRow> =
1424        attention.iter().map(|a| (a.id.as_str(), a)).collect();
1425    let mut ledger = Vec::new();
1426    for row in rows {
1427        let task_records: Vec<&LedgerRecord> =
1428            records.iter().filter(|r| r.task == row.id).collect();
1429        if !task_records.is_empty() {
1430            for rec in &task_records {
1431                let log = relativize(&rec.log_path, workspace_root);
1432                ledger.push(LedgerEntry {
1433                    task: row.id.clone(),
1434                    from: rec.from.clone(),
1435                    to: rec.to.clone(),
1436                    driver: rec.driver,
1437                    invocation: format!("{} / {}", rec.driver, log),
1438                    reason: ledger_outcome_reason(&rec.outcome, rec.exit_code),
1439                });
1440            }
1441            // If the task ended in a terminal-success state past the last spawned
1442            // transition, a callback or transition rule carried it the rest of the
1443            // way — record that advance so the ledger reaches the final state.
1444            let last_to = task_records.last().map(|r| r.to.as_str());
1445            if matches!(row.marker, Marker::Done | Marker::TerminalAtStart)
1446                && last_to != Some(row.state.as_str())
1447            {
1448                ledger.push(LedgerEntry {
1449                    task: row.id.clone(),
1450                    from: last_to.unwrap_or("").to_string(),
1451                    to: row.state.clone(),
1452                    driver: "callback-only",
1453                    invocation: "none".to_string(),
1454                    reason: "advanced without spawning work".to_string(),
1455                });
1456            }
1457            continue;
1458        }
1459
1460        // No invocation ran for this task this run — classify why it sits where
1461        // it does from the plan and the initial-state snapshot.
1462        let initial = initial_states.get(&row.id).map(String::as_str);
1463        if row.marker == Marker::TerminalAtStart {
1464            ledger.push(LedgerEntry {
1465                task: row.id.clone(),
1466                from: row.state.clone(),
1467                to: "-".to_string(),
1468                driver: "terminal-at-start",
1469                invocation: "none".to_string(),
1470                reason: "already terminal".to_string(),
1471            });
1472        } else if matches!(row.marker, Marker::Attention | Marker::Gate)
1473            // §FS-rhei-run-report.4: the parent is not a blocked row of its own
1474            // — see [`is_calm_parent`].
1475            && !is_calm_parent(
1476                &row.id,
1477                &row.state,
1478                machines.for_task(&parse_task_id(&row.id)),
1479                halt_causes,
1480            )
1481        {
1482            let reason = attention_by_id
1483                .get(row.id.as_str())
1484                .map(|a| a.reason.clone())
1485                .unwrap_or_else(|| format!("stalled in non-terminal state {}", row.state));
1486            ledger.push(LedgerEntry {
1487                task: row.id.clone(),
1488                from: row.state.clone(),
1489                to: "-".to_string(),
1490                driver: "blocked",
1491                invocation: "none".to_string(),
1492                reason,
1493            });
1494        } else if initial != Some(row.state.as_str()) {
1495            // Advanced to a new state without spawning a subprocess: callbacks,
1496            // transition rules, or already-present outputs carried it forward.
1497            ledger.push(LedgerEntry {
1498                task: row.id.clone(),
1499                from: initial.unwrap_or("").to_string(),
1500                to: row.state.clone(),
1501                driver: "callback-only",
1502                invocation: "none".to_string(),
1503                reason: "advanced without spawning work".to_string(),
1504            });
1505        } else if is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id))) {
1506            ledger.push(LedgerEntry {
1507                task: row.id.clone(),
1508                from: row.state.clone(),
1509                to: "-".to_string(),
1510                driver: "terminal-at-start",
1511                invocation: "none".to_string(),
1512                reason: "already terminal".to_string(),
1513            });
1514        }
1515    }
1516    ledger
1517}
1518
1519/// Collect spawned agents/programs for the Invocations section. §FS-rhei-run-report.7
1520fn build_invocations(
1521    records: &[LedgerRecord],
1522    workspace_root: &std::path::Path,
1523) -> Vec<InvocationRow> {
1524    records
1525        .iter()
1526        .map(|rec| InvocationRow {
1527            driver: rec.driver,
1528            task: rec.task.clone(),
1529            exit: match (&rec.outcome, rec.exit_code) {
1530                (LedgerOutcome::Cancelled, _) => "cancelled".to_string(),
1531                (LedgerOutcome::TimedOut, _) => "timed out".to_string(),
1532                (LedgerOutcome::Interrupted, _) => "interrupted".to_string(),
1533                (_, Some(code)) => format!("exit {code}"),
1534                (_, None) => "—".to_string(),
1535            },
1536            duration_ms: rec.duration_ms,
1537            log: relativize(&rec.log_path, workspace_root),
1538        })
1539        .collect()
1540}
1541
1542fn format_work(agents: u32, programs: u32, callback_only: u32) -> String {
1543    let mut parts = vec![format!("{agents} agents"), format!("{programs} programs")];
1544    if callback_only > 0 {
1545        parts.push(format!("{callback_only} callback-only"));
1546    }
1547    parts.join(" · ")
1548}
1549
1550fn marker_order(marker: Marker) -> u8 {
1551    match marker {
1552        Marker::Done => 0,
1553        Marker::Gate => 1,
1554        Marker::Attention => 2,
1555        Marker::Cancelled => 3,
1556        Marker::TerminalAtStart => 4,
1557    }
1558}
1559
1560fn format_duration_short(ms: u64) -> String {
1561    if ms < 60_000 {
1562        format!("{:.1}s", ms as f64 / 1000.0)
1563    } else {
1564        format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1000)
1565    }
1566}
1567
1568fn format_duration_long(d: std::time::Duration) -> String {
1569    let secs = d.as_secs();
1570    if secs < 60 {
1571        format!("{:.1}s", d.as_secs_f64())
1572    } else {
1573        format!("{}m{:02}s", secs / 60, secs % 60)
1574    }
1575}
1576
1577/// ANSI palette gated by a single `color` flag, so the renderer stays one code
1578/// path for both colored and plain output.
1579struct Palette {
1580    color: bool,
1581    reset: &'static str,
1582    bold: &'static str,
1583    dim: &'static str,
1584    red: &'static str,
1585}
1586
1587impl Palette {
1588    fn new(color: bool) -> Self {
1589        Self {
1590            color,
1591            reset: if color { RESET } else { "" },
1592            bold: if color { BOLD } else { "" },
1593            dim: if color { DIM } else { "" },
1594            red: if color { RED } else { "" },
1595        }
1596    }
1597
1598    fn color(&self, code: &'static str) -> &'static str {
1599        if self.color {
1600            code
1601        } else {
1602            ""
1603        }
1604    }
1605
1606    fn colored(&self, code: &'static str, text: &str) -> String {
1607        if self.color {
1608            format!("{code}{text}{RESET}")
1609        } else {
1610            text.to_string()
1611        }
1612    }
1613
1614    fn result_color(&self, result: &str) -> &'static str {
1615        if !self.color {
1616            return "";
1617        }
1618        if result.starts_with("stopped — ") {
1619            RED
1620        } else if result.starts_with("interrupted") {
1621            // Not red: an interrupted run is a run the operator stopped, not a
1622            // run that went wrong. §FS-rhei-run-report.3.1
1623            YELLOW
1624        } else if result.starts_with("stopped") {
1625            YELLOW
1626        } else if result == "completed" {
1627            GREEN
1628        } else {
1629            ""
1630        }
1631    }
1632}
1633
1634#[cfg(test)]
1635mod run_summary_tests {
1636    use super::*;
1637
1638    fn machine() -> rhei_validator::StateMachine {
1639        rhei_validator::StateMachine::builtin_default()
1640    }
1641
1642    /// Parse a tiny plan whose tasks carry the given `(id, state)` pairs.
1643    fn report(tasks: &[(&str, &str)]) -> RunSummaryReport {
1644        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1645        for (id, state) in tasks {
1646            md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1647        }
1648        let rhei = rhei_core::parse(&md).expect("plan parses");
1649        RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), test_stats(), "plan.rhei.md")
1650    }
1651
1652    /// `RunStats` with non-zero spawn counts and empty run metadata, for the
1653    /// renderer tests that do not exercise the durable header.
1654    fn test_stats() -> RunStats {
1655        RunStats {
1656            agents_spawned: 2,
1657            programs_spawned: 3,
1658            callback_only: 0,
1659            duration: Some(std::time::Duration::from_secs(5)),
1660            dashboard: None,
1661            run_id: "abc123".to_string(),
1662            started_at: Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_749_115_351)),
1663            workspace_root: std::path::PathBuf::from("examples/test"),
1664            command: "rhei run .".to_string(),
1665            parallel: 4,
1666            mode: "agent",
1667            initial_states: HashMap::new(),
1668            dry_run: false,
1669            interrupted: false,
1670        }
1671    }
1672
1673    #[test]
1674    fn markers_classify_by_state_class() {
1675        let m = machine();
1676        assert_eq!(classify_marker("completed", &m), Marker::Done);
1677        assert_eq!(classify_marker("blocked", &m), Marker::Attention);
1678        assert_eq!(classify_marker("cancelled", &m), Marker::Cancelled);
1679    }
1680
1681    /// A parent halted only because its own subtree is open is the eligibility
1682    /// rule working, so it reads as a calm pause. Classifying by state alone
1683    /// turned every ancestor of one gated leaf into its own red Attention row.
1684    // §FS-rhei-run-report.3.2
1685    #[test]
1686    fn a_parent_waiting_on_its_subtree_reads_as_a_calm_pause() {
1687        let m = machine();
1688        let mut causes: HashMap<String, HaltCause> = HashMap::new();
1689        causes.insert(
1690            "plan.1".to_string(),
1691            HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (human-gate)".to_string() },
1692        );
1693        causes.insert("plan.2".to_string(), HaltCause::Stalled);
1694
1695        // Same state, same machine: only the halt cause separates the two.
1696        assert_eq!(classify_marker("pending", &m), Marker::Attention);
1697        assert_eq!(marker_for_task("plan.1", "pending", &m, &causes), Marker::Gate);
1698        assert_eq!(marker_for_task("plan.2", "pending", &m, &causes), Marker::Attention);
1699        assert_eq!(marker_for_task("plan.3", "pending", &m, &causes), Marker::Attention);
1700
1701        // The reason still names the descendants, and the row still counts as
1702        // a gate rather than as something broken.
1703        let (reason, _) = attention_reason(Marker::Gate, "plan.1", "pending", &causes);
1704        assert!(
1705            reason.contains("waiting on open descendant Task plan.1.1 (human-gate)"),
1706            "{reason}"
1707        );
1708    }
1709
1710    /// One gated leaf under three ancestors is one thing needing a human, so
1711    /// the report counts it once. Treating each ancestor as halted work of its
1712    /// own gave four Attention rows, `4 gated`, `could not advance | 4`, and
1713    /// four blocked ledger rows for a single decision — and the topmost
1714    /// parent's reason text repeated the whole transitive subtree.
1715    // §FS-rhei-run-report.3.1 §FS-rhei-run-report.4 §FS-rhei-plan-language.3
1716    #[test]
1717    fn one_gate_under_three_ancestors_is_counted_once() {
1718        let rhei = rhei_core::parse(
1719            r#"# Rhei: Deep Subtree
1720---
1721structure:
1722  maxLevels: 4
1723---
1724
1725## Tasks
1726
1727### Task 1: Top
1728**State:** work
1729
1730#### Task 1.1: Middle
1731**State:** work
1732
1733##### Task 1.1.1: Inner
1734**State:** work
1735
1736###### Task 1.1.1.1: Gated leaf
1737**State:** human-gate
1738"#,
1739        )
1740        .expect("plan parses");
1741        let machine = rhei_validator::StateMachine::from_yaml_str(
1742            r#"name: t
1743version: 1
1744states:
1745  work:
1746    initial: true
1747    description: work
1748  human-gate:
1749    description: awaiting a human
1750    gating: true
1751  done:
1752    description: terminal
1753    final: true
1754transitions:
1755  - from: work
1756    to: done
1757  - from: human-gate
1758    to: done
1759"#,
1760        )
1761        .expect("valid state machine");
1762        let report = RunSummaryReport::build(
1763            &rhei,
1764            &rhei_validator::MachineSet::single(machine),
1765            &SummarySink::new(),
1766            test_stats(),
1767            "plan.rhei.md",
1768        );
1769
1770        assert_eq!(
1771            report.attention.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
1772            vec!["1.1.1.1"],
1773            "only the gate itself is halted work"
1774        );
1775
1776        let tty = report.render_tty(false);
1777        assert!(tty.contains("Attention  1 gated · 0 blocked"), "{tty}");
1778
1779        let markdown = report.render_markdown();
1780        assert!(markdown.contains("| could not advance | 1 |"), "{markdown}");
1781        assert_eq!(
1782            report.ledger.iter().filter(|e| e.driver == "blocked").count(),
1783            1,
1784            "one blocked ledger row, not one per ancestor"
1785        );
1786
1787        // The ancestors stay visible in the tree, calm and specific about what
1788        // holds them. §FS-rhei-run-report.3.2
1789        for id in ["1", "1.1", "1.1.1"] {
1790            let row = report.rows.iter().find(|r| r.id == id).expect("row present");
1791            assert_eq!(row.marker, Marker::Gate, "{id}");
1792            assert!(
1793                row.detail.as_deref().is_some_and(|d| d.contains("waiting on open descendant")),
1794                "{id}: {:?}",
1795                row.detail
1796            );
1797        }
1798    }
1799
1800    /// A parent that is itself blocked keeps its own attention marker: that is
1801    /// wrong independently of whatever its children are doing.
1802    // §FS-rhei-run-report.3.2
1803    #[test]
1804    fn a_failed_parent_keeps_its_attention_marker() {
1805        let m = machine();
1806        let mut causes: HashMap<String, HaltCause> = HashMap::new();
1807        causes.insert(
1808            "plan.1".to_string(),
1809            HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (pending)".to_string() },
1810        );
1811        assert_eq!(marker_for_task("plan.1", "blocked", &m, &causes), Marker::Attention);
1812    }
1813
1814    #[test]
1815    fn plain_render_lists_every_task_with_state() {
1816        let r = report(&[("1", "completed"), ("2", "blocked")]);
1817        let out = r.render_tty(false);
1818        assert!(out.contains("Run Report"), "{out}");
1819        assert!(out.contains("Test Plan"), "{out}");
1820        assert!(out.contains("completed"), "{out}");
1821        assert!(out.contains("blocked"), "{out}");
1822        // No ANSI escapes when color is disabled.
1823        assert!(!out.contains('\x1b'), "{out}");
1824    }
1825
1826    #[test]
1827    fn attention_block_surfaces_blocked_tasks() {
1828        let r = report(&[("1", "completed"), ("2", "blocked")]);
1829        let out = r.render_tty(false);
1830        assert!(out.contains("Attention"), "{out}");
1831        assert!(out.contains("1 blocked"), "{out}");
1832        assert!(out.contains("stopped for human attention"), "{out}");
1833    }
1834
1835    #[test]
1836    fn all_completed_reads_as_completed() {
1837        let r = report(&[("1", "completed"), ("2", "completed")]);
1838        let out = r.render_tty(false);
1839        assert!(out.contains("completed"), "{out}");
1840        assert!(!out.contains("Attention"), "{out}");
1841    }
1842
1843    #[test]
1844    fn color_render_emits_ansi() {
1845        let r = report(&[("1", "blocked")]);
1846        let out = r.render_tty(true);
1847        assert!(out.contains('\x1b'), "expected ANSI escapes");
1848    }
1849
1850    #[test]
1851    fn duration_formats_short_and_long() {
1852        assert_eq!(format_duration_short(200), "0.2s");
1853        assert_eq!(format_duration_short(8_100), "8.1s");
1854        assert_eq!(format_duration_short(65_000), "1m05s");
1855        assert_eq!(format_duration_long(std::time::Duration::from_secs(724)), "12m04s");
1856    }
1857
1858    /// Build a report from `(id, state)` pairs and a custom `RunStats`, used by
1859    /// the durable-report tests that vary spawn counts and initial states.
1860    fn report_with(tasks: &[(&str, &str)], stats: RunStats) -> RunSummaryReport {
1861        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1862        for (id, state) in tasks {
1863            md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1864        }
1865        let rhei = rhei_core::parse(&md).expect("plan parses");
1866        RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), stats, "plan.rhei.md")
1867    }
1868
1869    #[test]
1870    fn markdown_report_has_all_sections() {
1871        let r = report(&[("1", "completed"), ("2", "blocked")]);
1872        let md = r.render_markdown();
1873        assert!(md.starts_with("# Run Report: Test Plan"), "{md}");
1874        assert!(md.contains("Run: 2025-"), "header carries the ISO start: {md}");
1875        assert!(md.contains("| Final states | Count |"), "{md}");
1876        assert!(md.contains("| Activity | Count |"), "{md}");
1877        assert!(md.contains("## Attention"), "{md}");
1878        assert!(md.contains("## Transition Ledger"), "{md}");
1879        assert!(md.contains("## Task Final States"), "{md}");
1880    }
1881
1882    #[test]
1883    fn run_id_is_stable_for_a_given_start() {
1884        let t = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(1_749_115_351_123_456);
1885        assert_eq!(short_run_id(t), short_run_id(t));
1886        assert_eq!(short_run_id(t).len(), 6);
1887    }
1888
1889    #[test]
1890    fn no_work_run_that_advanced_reads_differently() {
1891        // Every task ended completed, nothing spawned, and a task moved off its
1892        // non-terminal start — the report must not look like fast agent work.
1893        // §FS-rhei-run-report.3.3
1894        let mut initial = HashMap::new();
1895        initial.insert("1".to_string(), "queued".to_string());
1896        let stats = RunStats {
1897            agents_spawned: 0,
1898            programs_spawned: 0,
1899            callback_only: 1,
1900            initial_states: initial,
1901            ..test_stats()
1902        };
1903        let r = report_with(&[("1", "completed")], stats);
1904        assert_eq!(r.result, "completed — no work spawned");
1905        let md = r.render_markdown();
1906        assert!(md.contains("No agent or program ran"), "{md}");
1907        // The advance with no invocation is a callback-only ledger row.
1908        assert!(md.contains("| 1 | queued | completed | callback-only |"), "{md}");
1909    }
1910
1911    #[test]
1912    fn terminal_at_start_task_is_marked_calm() {
1913        let mut initial = HashMap::new();
1914        initial.insert("done".to_string(), "completed".to_string());
1915        let stats = RunStats { initial_states: initial, ..test_stats() };
1916        let r = report_with(&[("done", "completed")], stats);
1917        assert_eq!(r.terminal_at_start, 1);
1918        let md = r.render_markdown();
1919        assert!(md.contains("terminal at start"), "{md}");
1920        // It is a terminal-at-start ledger row, not an invocation.
1921        assert!(md.contains("| done | completed | - | terminal-at-start |"), "{md}");
1922    }
1923
1924    #[test]
1925    fn write_to_runtime_emits_latest_and_history() {
1926        let dir = tempfile::tempdir().expect("tmpdir");
1927        let runtime = dir.path().join("runtime");
1928        let stats =
1929            RunStats { workspace_root: dir.path().to_path_buf(), ..test_stats() };
1930        let mut r = report_with(&[("1", "completed")], stats);
1931        r.write_to_runtime(&runtime).expect("write report");
1932        assert!(runtime.join("run-report.md").exists());
1933        assert_eq!(r.report_path.as_deref(), Some("runtime/run-report.md"));
1934        let history = std::fs::read_dir(runtime.join("run-reports"))
1935            .expect("history dir")
1936            .filter_map(Result::ok)
1937            .count();
1938        assert_eq!(history, 1, "one timestamped history entry written");
1939    }
1940
1941    /// The result follows the reading the run took when its loop ended, not
1942    /// the process-wide token at report time: a signal that arrives after the
1943    /// run finished — while the TUI is parked on its finished screen — leaves
1944    /// the run its own result.
1945    // §FS-rhei-run.3.2 §FS-rhei-run-report.3.1
1946    #[test]
1947    fn a_signal_after_the_loop_finished_does_not_relabel_the_result() {
1948        let finished = report_with(&[("1", "completed")], test_stats());
1949        assert_eq!(finished.result, "completed");
1950        let cut_short =
1951            report_with(&[("1", "completed")], RunStats { interrupted: true, ..test_stats() });
1952        assert_eq!(cut_short.result, "interrupted — re-run to continue");
1953    }
1954
1955    #[test]
1956    fn dry_run_result_reads_as_preview() {
1957        let stats = RunStats { dry_run: true, ..test_stats() };
1958        let r = report_with(&[("1", "completed")], stats);
1959        assert_eq!(r.result, "dry run — no changes applied");
1960        assert!(r.render_markdown().contains("Result: dry run — no changes applied"));
1961    }
1962
1963    #[test]
1964    fn dashboard_pointer_gated_on_enabled_this_run() {
1965        let dir = tempfile::tempdir().expect("tmpdir");
1966        let runtime = dir.path().join("runtime");
1967        std::fs::create_dir_all(&runtime).unwrap();
1968        std::fs::write(runtime.join("dashboard.html"), "<html>").unwrap();
1969        // A stale dashboard from an earlier run must not be linked when the
1970        // dashboard was off this run.
1971        assert_eq!(frozen_dashboard_relative_path(false, &runtime, dir.path()), None);
1972        assert_eq!(
1973            frozen_dashboard_relative_path(true, &runtime, dir.path()).as_deref(),
1974            Some("runtime/dashboard.html"),
1975        );
1976    }
1977
1978    #[test]
1979    fn md_cell_escapes_pipes_and_newlines() {
1980        assert_eq!(md_cell("a|b"), "a\\|b");
1981        assert_eq!(md_cell("line1\nline2"), "line1 line2");
1982    }
1983
1984    /// A `SummarySink` carrying one spawned transition `from`→`to`.
1985    fn summary_with_spawn(task: &str, from: &str, to: &str, agent: bool) -> SummarySink {
1986        use rhei_tui::EventSink;
1987        let s = SummarySink::new();
1988        let log = std::path::PathBuf::from("runtime/logs/x.log");
1989        s.emit(rhei_tui::RunEvent::SlotAssigned {
1990            slot: 0,
1991            task: task.to_string(),
1992            from: from.to_string(),
1993            to: to.to_string(),
1994            agent: agent.then(|| "mock".to_string()),
1995            template_context: None,
1996            log_path: log.clone(),
1997            started_at: std::time::Instant::now(),
1998            wall_clock: std::time::SystemTime::now(),
1999        });
2000        s.emit(rhei_tui::RunEvent::SlotReleased {
2001            slot: 0,
2002            task: task.to_string(),
2003            from: from.to_string(),
2004            to: to.to_string(),
2005            log_path: log,
2006            outcome: rhei_tui::TaskOutcome::Completed,
2007            finished_at: std::time::Instant::now(),
2008            wall_clock: std::time::SystemTime::now(),
2009            exit_code: Some(0),
2010            duration_ms: 1_200,
2011        });
2012        s
2013    }
2014
2015    #[test]
2016    fn ledger_records_trailing_callback_advance_after_spawn() {
2017        // An agent ran build->review, then a callback carried review->completed
2018        // with no further spawn. The ledger must reach the final state.
2019        let summary = summary_with_spawn("1", "build", "review", true);
2020        let stats = RunStats { initial_states: HashMap::new(), ..test_stats() };
2021        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
2022        md.push_str("### Task 1: Task 1\n**State:** completed\n\n");
2023        let rhei = rhei_core::parse(&md).expect("plan parses");
2024        let report = RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &summary, stats, "plan.rhei.md");
2025        let md = report.render_markdown();
2026        // The spawned agent row and the synthesized callback advance both appear.
2027        assert!(md.contains("| 1 | build | review | agent |"), "{md}");
2028        assert!(md.contains("| 1 | review | completed | callback-only |"), "{md}");
2029    }
2030}