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