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        // §FS-rhei-states.1.4: the reserved cancel name, in either spelling.
437        _ if rhei_validator::is_cancelled_state_name(state) => return Marker::Cancelled,
438        _ if state_is_failure(state) => return Marker::Attention,
439        _ => {}
440    }
441    let def = machine.states.get(state);
442    if def.map(|d| d.gating).unwrap_or(false) {
443        Marker::Gate
444    } else if def.map(|d| d.terminal).unwrap_or(false) {
445        Marker::Done
446    } else {
447        Marker::Attention
448    }
449}
450
451/// The marker for one task row, with the run's own halt classification allowed
452/// to overrule the state-based reading.
453///
454/// A parent held open only by its own subtree is the eligibility rule working,
455/// not something wrong — and since every ancestor of one gated leaf is halted
456/// this way, classifying by state alone painted a whole spine of the tree red.
457/// It reads as a deliberate pause instead, exactly like the gate that is
458/// really holding it. A parent that is itself `blocked` or `failed` keeps its
459/// own attention marker: that is wrong independently of its children.
460// §FS-rhei-run-report.3.2
461fn marker_for_task(
462    id: &str,
463    state: &str,
464    machine: &rhei_validator::StateMachine,
465    halt_causes: &HashMap<String, HaltCause>,
466) -> Marker {
467    // A held descendant is a deliberate pause, not work to act on: its
468    // supervisor is the ticket that is owed a visit, and it takes the
469    // Attention row. §FS-rhei-supervision.3.4
470    if matches!(halt_causes.get(id), Some(HaltCause::HeldBySupervisor { .. }))
471        && !state_is_failure(state)
472    {
473        return Marker::Gate;
474    }
475    if is_calm_parent(id, state, machine, halt_causes) {
476        return Marker::Gate;
477    }
478    classify_marker(state, machine)
479}
480
481/// Whether this ticket is a parent held open *only* by its own subtree: the run
482/// classified it as waiting on descendants, and its own state neither reads as
483/// failure nor is pending a decision of its own.
484///
485/// Such a parent is not halted work — the open descendant is, and it reports
486/// for itself. It therefore takes no Attention row, no `N gated` tally, no
487/// `could not advance` count, and no blocked ledger entry; one gated leaf under
488/// three ancestors otherwise produced four of each, with the topmost parent's
489/// reason text repeating the whole transitive subtree. It keeps its calm marker
490/// and its `waiting on open descendant …` detail in the task tree, which is
491/// where the structure is worth showing.
492///
493/// A parent that is itself gating, `blocked`, or `failed` is excluded: those
494/// are things to act on independently of what its children are doing, even
495/// though the open subtree outranks them in the classification order.
496// §FS-rhei-run-report.3.1 §FS-rhei-run-report.3.2 §FS-rhei-plan-language.3
497fn is_calm_parent(
498    id: &str,
499    state: &str,
500    machine: &rhei_validator::StateMachine,
501    halt_causes: &HashMap<String, HaltCause>,
502) -> bool {
503    classify_marker(state, machine) == Marker::Attention
504        && !state_is_failure(state)
505        && matches!(halt_causes.get(id), Some(HaltCause::WaitingOnDescendants { .. }))
506}
507
508/// One row of the source-order task tree.
509struct TaskRow {
510    depth: usize,
511    id: String,
512    state: String,
513    marker: Marker,
514    /// Driver + timing for advanced tasks, or a short reason for halted ones.
515    detail: Option<String>,
516}
517
518/// A halted task surfaced in the Attention group, with its proven blocker and
519/// the next action. §FS-rhei-run-report.3.1
520struct AttentionRow {
521    id: String,
522    state: String,
523    reason: String,
524    next: String,
525    /// True for a deliberate pause — a gating state awaiting a human, or a
526    /// parent held open by its own subtree; false for a blocked/failed task.
527    /// It splits the `N gated · M blocked` header. §FS-rhei-run-report.3.1
528    is_gate: bool,
529}
530
531/// Run-level facts the summary needs beyond the plan itself. §FS-rhei-run-report.8
532pub struct RunStats {
533    pub agents_spawned: u32,
534    pub programs_spawned: u32,
535    pub callback_only: u32,
536    pub duration: Option<std::time::Duration>,
537    pub dashboard: Option<String>,
538    /// Short run identifier shown in the header and history filename.
539    pub run_id: String,
540    /// Wall-clock start, rendered in the report header. `None` falls back to
541    /// the run id alone.
542    pub started_at: Option<std::time::SystemTime>,
543    /// Workspace root, used to render relative artifact links. §FS-rhei-run-report.1
544    pub workspace_root: std::path::PathBuf,
545    /// The command label shown in the header (`rhei run …`).
546    pub command: String,
547    /// Worker parallelism for the run.
548    pub parallel: usize,
549    /// `"agent"` or `"callback"` execution mode.
550    pub mode: &'static str,
551    /// Task id → normalized state at run start, for terminal-at-start detection
552    /// and reconciling callback advances that emit no slot events.
553    /// §FS-rhei-run-report.8
554    pub initial_states: HashMap<String, String>,
555    /// True under `--dry-run`: the report records a simulated run that applied no
556    /// changes, so its result line and counts read as a preview. §FS-rhei-run-report.3.5
557    pub dry_run: bool,
558    /// True when the run's own loop was cut short by a signal, captured where
559    /// that loop ends: a run already finished when the signal arrived — parked
560    /// on the TUI's finished screen — has a result of its own to report.
561    // §FS-rhei-run.3.2
562    pub interrupted: bool,
563}
564
565/// One rendered Transition Ledger row. §FS-rhei-run-report.4
566struct LedgerEntry {
567    task: String,
568    from: String,
569    /// Destination state, or `-` when no transition was taken.
570    to: String,
571    /// `agent`, `program`, `callback-only`, `terminal-at-start`, or `blocked`.
572    driver: &'static str,
573    /// Invocation label + relative log link, or `none`.
574    invocation: String,
575    reason: String,
576}
577
578/// One spawned agent/program for the Invocations section. §FS-rhei-run-report.7
579struct InvocationRow {
580    driver: &'static str,
581    task: String,
582    /// `exit 0`, `exit 42`, `cancelled`, `timed out`, or `—`.
583    exit: String,
584    duration_ms: u64,
585    /// Relative log path.
586    log: String,
587}
588
589/// Direct accounting shown for a task in the end-of-run report.
590struct TaskAccountingRow {
591    task: String,
592    cost: String,
593    total: String,
594    input: String,
595    input_cached: String,
596    output: String,
597    output_cached: String,
598    coverage: String,
599}
600
601/// The fully resolved run report, ready to render to the console or to Markdown.
602pub struct RunSummaryReport {
603    title: String,
604    result: String,
605    duration: Option<std::time::Duration>,
606    /// State label, count, and marker class, in canonical count order.
607    state_counts: Vec<(String, usize, Marker)>,
608    total_tasks: usize,
609    work: String,
610    accounting: Option<rhei_tui::AccountingRunSummary>,
611    attention: Vec<AttentionRow>,
612    /// Tickets nobody has to act on because someone else's turn is what they
613    /// are waiting for. Held descendants dilute Attention: a held ticket's own
614    /// next action is "nothing to do on this ticket". §FS-rhei-supervision.3.4
615    waiting: Vec<AttentionRow>,
616    rows: Vec<TaskRow>,
617    dashboard: Option<String>,
618    // ── Durable-report fields (§FS-rhei-run-report.1, .2, .4, .7) ────────────
619    run_id: String,
620    started_at: Option<std::time::SystemTime>,
621    workspace: String,
622    command: String,
623    parallel: usize,
624    mode: &'static str,
625    agents_spawned: u32,
626    programs_spawned: u32,
627    callback_only: u32,
628    terminal_at_start: usize,
629    ledger: Vec<LedgerEntry>,
630    invocations: Vec<InvocationRow>,
631    task_accounting: Vec<TaskAccountingRow>,
632    /// Relative paths to the written report files, filled by [`write_to_runtime`].
633    report_path: Option<String>,
634    history_path: Option<String>,
635}
636
637// ANSI codes; emitted only when color is enabled.
638const RESET: &str = "\x1b[0m";
639const BOLD: &str = "\x1b[1m";
640const DIM: &str = "\x1b[2m";
641const RED: &str = "\x1b[31m";
642const GREEN: &str = "\x1b[32m";
643const YELLOW: &str = "\x1b[33m";
644
645/// Width of the static state-distribution bar, in cells.
646const BAR_WIDTH: usize = 24;
647/// Maximum task rows printed before fully-completed subtrees collapse.
648const MAX_TASK_ROWS: usize = 40;
649/// Maximum attention rows printed before the rest defer to the report.
650const MAX_ATTENTION_ROWS: usize = 5;
651
652impl RunSummaryReport {
653    /// Build the report from the on-disk plan, the run's spawn counts, and the
654    /// per-task activity captured by [`SummarySink`]. §FS-rhei-run-report.8
655    pub fn build(
656        rhei: &rhei_core::ast::Rhei,
657        machines: &rhei_validator::MachineSet,
658        summary: &SummarySink,
659        stats: RunStats,
660        plan_arg: &str,
661    ) -> Self {
662        let activity = summary.snapshot();
663        // Read once: the ledger answers both "why is this ticket halted" below
664        // and the report's own Transition Ledger further down.
665        let ledger_records = summary.ledger();
666        let ledger = &ledger_records;
667
668        // Why each halted ticket is halted, resolved once against the whole
669        // plan. The table below needs the plan's priors and claims, which a
670        // per-task walk cannot see. §FS-rhei-run-report.3.1
671        let halt_causes: HashMap<String, HaltCause> = classify_halted_tasks(
672            rhei,
673            machines,
674            &None,
675            &|id| activity.contains_key(id),
676            // §FS-rhei-run-report.3.1: what the ticket's last exit-0 worker
677            // left unwritten, captured live rather than re-read from prose, and
678            // only while the ticket still sits in the state it stalled in.
679            &|id, state| {
680                activity
681                    .get(id)
682                    .and_then(|entry| entry.missing_outputs.as_ref())
683                    .filter(|(stalled_in, entries)| stalled_in == state && !entries.is_empty())
684                    .map(|(_, entries)| entries.clone())
685            },
686            // Only the ticket's *last* invocation explains where it is, and
687            // only for a run the operator stopped: a failing run ends its
688            // workers the same way. §FS-rhei-run-report.3.1 §FS-rhei-run.3.2
689            &|id| {
690                stats.interrupted
691                    && matches!(
692                        ledger
693                            .iter()
694                            .rev()
695                            .find(|record| record.task == id)
696                            .map(|record| &record.outcome),
697                        Some(LedgerOutcome::Interrupted)
698                    )
699            },
700            plan_arg,
701        )
702        .into_iter()
703        .map(|(task, cause)| (task.id.to_string(), cause))
704        .collect();
705
706        // Source-order walk that preserves hierarchy depth.
707        let mut rows = Vec::new();
708        let mut attention = Vec::new();
709        let mut waiting = Vec::new();
710        let mut counts: std::collections::BTreeMap<String, (usize, Marker)> =
711            std::collections::BTreeMap::new();
712        collect_rows(
713            &rhei.tasks,
714            0,
715            machines,
716            &activity,
717            &halt_causes,
718            &mut rows,
719            &mut attention,
720            &mut waiting,
721            &mut counts,
722        );
723
724        // Terminal-at-start: same terminal state at run start as now, so no work
725        // was attempted. The row keeps its state count but flips to the calm `·`
726        // marker so it reads apart from work that just ran. §FS-rhei-run-report.3.2
727        let mut terminal_at_start = 0usize;
728        for row in &mut rows {
729            let was = stats.initial_states.get(&row.id).map(String::as_str);
730            let unchanged_terminal = was == Some(row.state.as_str())
731                && is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id)));
732            if unchanged_terminal {
733                terminal_at_start += 1;
734                // A success state flips to the calm `·` marker; a cancelled task
735                // keeps its own `⊘` marker but still counts as terminal-at-start.
736                if row.marker == Marker::Done {
737                    row.marker = Marker::TerminalAtStart;
738                    row.detail = Some("terminal at start".to_string());
739                }
740            }
741        }
742
743        let total_tasks = rows.len();
744
745        // Counts in canonical order: success, gate, attention, cancelled.
746        let mut state_counts: Vec<(String, usize, Marker)> =
747            counts.into_iter().map(|(state, (n, marker))| (state, n, marker)).collect();
748        state_counts.sort_by_key(|(_, _, marker)| marker_order(*marker));
749
750        let no_work = stats.agents_spawned == 0 && stats.programs_spawned == 0;
751        let advanced_without_work = rows.iter().any(|r| {
752            r.marker == Marker::Done
753                && stats.initial_states.get(&r.id).map(String::as_str) != Some(r.state.as_str())
754        });
755        // A dry run simulated transitions but applied nothing, so its result
756        // reads as a preview rather than an outcome. §FS-rhei-run-report.3.5
757        let result = if stats.dry_run {
758            "dry run — no changes applied".to_string()
759        } else {
760            // Why the loop ended, as the caller read it when it ended (see
761            // `result_phrase`). §FS-rhei-run.3.2 §FS-rhei-run-report.3.1
762            result_phrase(&attention, &rows, no_work, advanced_without_work, stats.interrupted)
763        };
764        let work = format_work(stats.agents_spawned, stats.programs_spawned, stats.callback_only);
765        let accounting = summary.accounting();
766        let task_accounting = build_task_accounting_rows(&rows, &activity);
767
768        let ledger_rows = build_ledger(
769            &rows,
770            &attention,
771            &halt_causes,
772            ledger,
773            &stats.initial_states,
774            machines,
775            &stats.workspace_root,
776        );
777        let invocations = build_invocations(ledger, &stats.workspace_root);
778
779        Self {
780            title: rhei.title.clone(),
781            result,
782            duration: stats.duration,
783            state_counts,
784            total_tasks,
785            work,
786            accounting,
787            attention,
788            waiting,
789            rows,
790            dashboard: stats.dashboard,
791            run_id: stats.run_id,
792            started_at: stats.started_at,
793            workspace: stats.workspace_root.display().to_string(),
794            command: stats.command,
795            parallel: stats.parallel,
796            mode: stats.mode,
797            agents_spawned: stats.agents_spawned,
798            programs_spawned: stats.programs_spawned,
799            callback_only: stats.callback_only,
800            terminal_at_start,
801            ledger: ledger_rows,
802            invocations,
803            task_accounting,
804            report_path: None,
805            history_path: None,
806        }
807    }
808
809    /// Render the rich, colored summary for an interactive terminal.
810    /// §FS-rhei-run-report.3.1
811    pub fn render_tty(&self, color: bool) -> String {
812        let c = Palette::new(color);
813        let mut out = String::new();
814
815        // Header: title + duration, then the result line.
816        let dur = self.duration.map(format_duration_long).unwrap_or_default();
817        out.push_str(&format!(
818            "\n{}Run Report{}  {}{}{}",
819            c.bold, c.reset, c.bold, self.title, c.reset
820        ));
821        if !dur.is_empty() {
822            out.push_str(&format!("   {}{}{}", c.dim, dur, c.reset));
823        }
824        out.push('\n');
825        out.push_str(&format!("  {}{}{}\n\n", c.result_color(&self.result), self.result, c.reset));
826
827        // Counts: distribution bar + labeled states, then work.
828        out.push_str("  States    ");
829        out.push_str(&self.render_bar(&c));
830        out.push_str("   ");
831        out.push_str(&self.render_state_labels(&c));
832        out.push('\n');
833        out.push_str(&format!("  Work      {}\n", self.work));
834        if let Some(accounting) = &self.accounting {
835            // §FS-rhei-cost-accounting.9: End-of-run surfaces show separate input,
836            // cached input, output, and cached output totals.
837            out.push_str(&format!(
838                "  Cost      {} · Total {} · In {} · In cached {} · Out {} · Out cached {} · Coverage {:?}\n",
839                format_summary_cost(accounting),
840                format_dimension_value(&accounting.total),
841                format_dimension_value(&accounting.input_total),
842                format_dimension_value(&accounting.input_cached_read),
843                format_dimension_value(&accounting.output_total),
844                format_dimension_value(&accounting.output_cached_read),
845                accounting.coverage,
846            ));
847        }
848
849        // Attention.
850        if !self.attention.is_empty() {
851            let gated = self.attention.iter().filter(|a| a.is_gate).count();
852            let blocked = self.attention.len() - gated;
853            out.push_str(&format!(
854                "\n{}Attention{}  {} gated · {} blocked\n",
855                c.bold, c.reset, gated, blocked
856            ));
857            for row in self.attention.iter().take(MAX_ATTENTION_ROWS) {
858                out.push_str(&format!(
859                    "  {}!{} {:<26} {}{:<11}{} {}\n",
860                    c.red, c.reset, row.id, c.dim, row.state, c.reset, row.reason
861                ));
862                out.push_str(&format!("        {}→ {}{}\n", c.dim, row.next, c.reset));
863            }
864            if self.attention.len() > MAX_ATTENTION_ROWS {
865                out.push_str(&format!(
866                    "  {}… {} more in the report{}\n",
867                    c.dim,
868                    self.attention.len() - MAX_ATTENTION_ROWS,
869                    c.reset
870                ));
871            }
872        }
873
874        // Waiting — held tickets, which are nobody's action item.
875        // §FS-rhei-supervision.3.4
876        if !self.waiting.is_empty() {
877            out.push_str(&format!(
878                "\n{}Waiting{}    {} held\n",
879                c.bold,
880                c.reset,
881                self.waiting.len()
882            ));
883            for row in self.waiting.iter().take(MAX_ATTENTION_ROWS) {
884                out.push_str(&format!(
885                    "  {}\u{23f8}{} {:<26} {}{:<11}{} {}\n",
886                    c.dim, c.reset, row.id, c.dim, row.state, c.reset, row.reason
887                ));
888            }
889            if self.waiting.len() > MAX_ATTENTION_ROWS {
890                out.push_str(&format!(
891                    "  {}\u{2026} {} more in the report{}\n",
892                    c.dim,
893                    self.waiting.len() - MAX_ATTENTION_ROWS,
894                    c.reset
895                ));
896            }
897        }
898
899        // Task tree.
900        out.push_str(&format!(
901            "\n{}Tasks{}   {} tasks · source order\n",
902            c.bold, c.reset, self.total_tasks
903        ));
904        out.push_str(&self.render_tree(&c));
905
906        // Pointers: the durable report is the at-a-glance summary's companion;
907        // the console points at it for the full forensic read. §FS-rhei-run-report.3.1
908        out.push('\n');
909        if let Some(report) = &self.report_path {
910            out.push_str(&format!("Report     {report}\n"));
911        }
912        if let Some(history) = &self.history_path {
913            out.push_str(&format!("History    {history}\n"));
914        }
915        if let Some(dashboard) = &self.dashboard {
916            out.push_str(&format!("Dashboard  {dashboard}\n"));
917        }
918        // Drop trailing spaces left by empty detail columns; keep the final newline.
919        let trailing_newline = out.ends_with('\n');
920        let mut trimmed = out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
921        if trailing_newline {
922            trimmed.push('\n');
923        }
924        trimmed
925    }
926
927    /// Render the durable Markdown report — header, outcome strip, attention,
928    /// ledger, task final states, invocations: the commit-friendly explanation
929    /// an operator can read without the dashboard. §FS-rhei-run-report.1 §FS-rhei-run-report.2
930    pub fn render_markdown(&self) -> String {
931        let mut out = String::new();
932
933        // 1. Header.
934        out.push_str(&format!("# Run Report: {}\n\n", self.title));
935        let when = self
936            .started_at
937            .map(format_iso8601_utc)
938            .map(|ts| format!("{ts} / {}", self.run_id))
939            .unwrap_or_else(|| self.run_id.clone());
940        out.push_str(&format!("Run: {when}\n"));
941        out.push_str(&format!("Workspace: {}\n", self.workspace));
942        out.push_str(&format!("Command: {}\n", self.command));
943        out.push_str(&format!("Mode: {} · parallel {}\n", self.mode, self.parallel));
944        if let Some(dur) = self.duration {
945            out.push_str(&format!("Duration: {}\n", format_duration_long(dur)));
946        }
947        out.push_str(&format!("Result: {}\n", self.result));
948        if let Some(dashboard) = &self.dashboard {
949            out.push_str(&format!("Dashboard: {dashboard}\n"));
950        }
951        out.push('\n');
952
953        // 2. Outcome strip — final states and run activity. The reuse/blocked
954        // signal sits at the top of the report, never below a fold.
955        out.push_str("| Final states | Count |\n| --- | ---: |\n");
956        for (state, n, _) in &self.state_counts {
957            out.push_str(&format!("| {state} | {n} |\n"));
958        }
959        out.push('\n');
960        let could_not_advance = self.attention.len();
961        out.push_str("| Activity | Count |\n| --- | ---: |\n");
962        out.push_str(&format!("| agent invocations | {} |\n", self.agents_spawned));
963        out.push_str(&format!("| program invocations | {} |\n", self.programs_spawned));
964        out.push_str(&format!("| callback-only transitions | {} |\n", self.callback_only));
965        out.push_str(&format!("| terminal at start | {} |\n", self.terminal_at_start));
966        out.push_str(&format!("| could not advance | {could_not_advance} |\n"));
967        out.push('\n');
968        if let Some(accounting) = &self.accounting {
969            // §FS-rhei-cost-accounting.9: Durable reports carry the run accounting strip.
970            out.push_str("| Accounting | Value |\n| --- | ---: |\n");
971            out.push_str(&format!("| cost | {} |\n", format_summary_cost(accounting)));
972            out.push_str(&format!(
973                "| total tokens | {} |\n",
974                format_dimension_value(&accounting.total)
975            ));
976            out.push_str(&format!(
977                "| input tokens | {} |\n",
978                format_dimension_value(&accounting.input_total)
979            ));
980            out.push_str(&format!(
981                "| input cached | {} |\n",
982                format_dimension_value(&accounting.input_cached_read)
983            ));
984            out.push_str(&format!(
985                "| output tokens | {} |\n",
986                format_dimension_value(&accounting.output_total)
987            ));
988            out.push_str(&format!(
989                "| output cached | {} |\n",
990                format_dimension_value(&accounting.output_cached_read)
991            ));
992            out.push_str(&format!("| coverage | {:?} |\n", accounting.coverage));
993            out.push('\n');
994        }
995        if self.agents_spawned == 0 && self.programs_spawned == 0 {
996            out.push_str(
997                "> No agent or program ran this run. Any task that advanced did so through \
998                 callbacks, transition rules, or outputs that already existed — inspect the \
999                 ledger below before assuming work was performed.\n\n",
1000            );
1001        }
1002
1003        // 3. Attention.
1004        if !self.attention.is_empty() {
1005            out.push_str("## Attention\n\n");
1006            out.push_str("| Task | State | Reason | Next action |\n| --- | --- | --- | --- |\n");
1007            for a in &self.attention {
1008                out.push_str(&format!(
1009                    "| {} | {} | {} | {} |\n",
1010                    md_cell(&a.id),
1011                    md_cell(&a.state),
1012                    md_cell(&a.reason),
1013                    md_cell(&a.next),
1014                ));
1015            }
1016            out.push('\n');
1017        }
1018
1019        // 3b. Waiting — held tickets, kept out of Attention so the rows a
1020        // person must act on stay undiluted. §FS-rhei-supervision.3.4
1021        if !self.waiting.is_empty() {
1022            out.push_str("## Waiting\n\n");
1023            out.push_str("| Task | State | Reason | Next action |\n| --- | --- | --- | --- |\n");
1024            for row in &self.waiting {
1025                out.push_str(&format!(
1026                    "| {} | {} | {} | {} |\n",
1027                    md_cell(&row.id),
1028                    md_cell(&row.state),
1029                    md_cell(&row.reason),
1030                    md_cell(&row.next),
1031                ));
1032            }
1033            out.push('\n');
1034        }
1035
1036        // 4. Transition ledger.
1037        out.push_str("## Transition Ledger\n\n");
1038        out.push_str(
1039            "| Task | From | To | Driver | Invocation | Reason |\n\
1040             | --- | --- | --- | --- | --- | --- |\n",
1041        );
1042        for e in &self.ledger {
1043            out.push_str(&format!(
1044                "| {} | {} | {} | {} | {} | {} |\n",
1045                e.task,
1046                md_cell(&e.from),
1047                md_cell(&e.to),
1048                e.driver,
1049                md_link_or_text(&e.invocation),
1050                md_cell(&e.reason),
1051            ));
1052        }
1053        out.push('\n');
1054
1055        // 5. Task final states.
1056        out.push_str("## Task Final States\n\n");
1057        for row in &self.rows {
1058            let indent = "  ".repeat(row.depth);
1059            let detail = row.detail.as_deref().unwrap_or("");
1060            let detail = if detail.is_empty() {
1061                String::new()
1062            } else {
1063                format!(" — {detail}")
1064            };
1065            out.push_str(&format!(
1066                "{indent}- {} `{}` ({}){detail}\n",
1067                row.marker.glyph(),
1068                row.id,
1069                row.state,
1070            ));
1071        }
1072        out.push('\n');
1073
1074        if !self.task_accounting.is_empty() {
1075            out.push_str("## Task Costs\n\n");
1076            out.push_str(
1077                "| Task | Cost | Total | Input | Input cached | Output | Output cached | Coverage |\n\
1078                 | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n",
1079            );
1080            for row in &self.task_accounting {
1081                out.push_str(&format!(
1082                    "| {} | {} | {} | {} | {} | {} | {} | {} |\n",
1083                    md_cell(&row.task),
1084                    row.cost,
1085                    row.total,
1086                    row.input,
1087                    row.input_cached,
1088                    row.output,
1089                    row.output_cached,
1090                    row.coverage,
1091                ));
1092            }
1093            out.push('\n');
1094        }
1095
1096        // 6. Invocations.
1097        if !self.invocations.is_empty() {
1098            out.push_str("## Invocations\n\n");
1099            out.push_str(
1100                "| Task | Driver | Exit | Duration | Log |\n| --- | --- | --- | --- | --- |\n",
1101            );
1102            for inv in &self.invocations {
1103                out.push_str(&format!(
1104                    "| {} | {} | {} | {} | [{}]({}) |\n",
1105                    inv.task,
1106                    inv.driver,
1107                    inv.exit,
1108                    format_duration_short(inv.duration_ms),
1109                    inv.log,
1110                    inv.log,
1111                ));
1112            }
1113            out.push('\n');
1114        }
1115
1116        out
1117    }
1118
1119    /// Write the durable report to `runtime/run-report.md` and a timestamped
1120    /// history entry, recording the relative paths for the console pointer.
1121    /// Best-effort. §FS-rhei-run-report.1
1122    pub fn write_to_runtime(&mut self, runtime_dir: &std::path::Path) -> std::io::Result<()> {
1123        let body = self.render_markdown();
1124        let latest = runtime_dir.join("run-report.md");
1125        let history_dir = runtime_dir.join("run-reports");
1126        std::fs::create_dir_all(&history_dir)?;
1127        let stamp = self
1128            .started_at
1129            .map(format_iso8601_utc)
1130            .map(|ts| ts.replace(':', "-"))
1131            .unwrap_or_else(|| "unknown".to_string());
1132        let history = history_dir.join(format!("{stamp}-{}.md", self.run_id));
1133        std::fs::write(&latest, &body)?;
1134        std::fs::write(&history, &body)?;
1135        self.report_path = Some(relativize(&latest, &self.workspace_root_path()));
1136        self.history_path = Some(relativize(&history, &self.workspace_root_path()));
1137        Ok(())
1138    }
1139
1140    /// The workspace root reconstructed from its display string, for link bases.
1141    fn workspace_root_path(&self) -> std::path::PathBuf {
1142        std::path::PathBuf::from(&self.workspace)
1143    }
1144
1145    /// The static state-distribution bar, sized by count and colored by class.
1146    /// Drawn once; never animates. §FS-rhei-run-report.3.1 §FS-rhei-viz-ux.4
1147    fn render_bar(&self, c: &Palette) -> String {
1148        if self.total_tasks == 0 {
1149            return String::new();
1150        }
1151        // Proportional widths, with at least one cell per non-empty state.
1152        let mut widths: Vec<usize> = self
1153            .state_counts
1154            .iter()
1155            .map(|(_, n, _)| {
1156                let w = (*n * BAR_WIDTH) / self.total_tasks;
1157                if *n > 0 {
1158                    w.max(1)
1159                } else {
1160                    0
1161                }
1162            })
1163            .collect();
1164        // Trim overflow from the largest segment so total == BAR_WIDTH.
1165        let mut total: usize = widths.iter().sum();
1166        while total > BAR_WIDTH {
1167            if let Some((idx, _)) =
1168                widths.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w)
1169            {
1170                widths[idx] -= 1;
1171                total -= 1;
1172            } else {
1173                break;
1174            }
1175        }
1176        let mut bar = String::new();
1177        for ((_, _, marker), w) in self.state_counts.iter().zip(widths) {
1178            if w == 0 {
1179                continue;
1180            }
1181            bar.push_str(c.color(marker.color()));
1182            bar.push_str(&"█".repeat(w));
1183            bar.push_str(c.reset);
1184        }
1185        bar
1186    }
1187
1188    fn render_state_labels(&self, c: &Palette) -> String {
1189        self.state_counts
1190            .iter()
1191            .map(|(state, n, marker)| {
1192                format!("{}{} {}{}", c.color(marker.color()), n, state, c.reset)
1193            })
1194            .collect::<Vec<_>>()
1195            .join(" · ")
1196    }
1197
1198    fn render_tree(&self, c: &Palette) -> String {
1199        let mut out = String::new();
1200        let mut collapsed = 0usize;
1201        let mut shown = 0usize;
1202        for row in &self.rows {
1203            // Collapse calm completed leaf rows once the tree grows long, but
1204            // never hide anything that needs a human. §FS-rhei-run-report.3.2
1205            if shown >= MAX_TASK_ROWS && row.marker == Marker::Done {
1206                collapsed += 1;
1207                continue;
1208            }
1209            shown += 1;
1210            let gutter = if row.depth > 0 { "│ ".repeat(row.depth) } else { String::new() };
1211            let detail = row.detail.as_deref().unwrap_or("");
1212            // Pad the state column *outside* the color codes so that empty-detail
1213            // rows can have their trailing padding trimmed away.
1214            let state_cell = c.colored(row.marker.color(), &row.state);
1215            let state_pad = " ".repeat(11usize.saturating_sub(row.state.chars().count()));
1216            out.push_str(&format!(
1217                "  {}{}{}{} {:<width$} {}{} {}\n",
1218                c.dim,
1219                gutter,
1220                c.reset,
1221                c.colored(row.marker.color(), &row.marker.glyph().to_string()),
1222                row.id,
1223                state_cell,
1224                state_pad,
1225                detail,
1226                width = 26usize.saturating_sub(row.depth * 2),
1227            ));
1228        }
1229        if collapsed > 0 {
1230            out.push_str(&format!(
1231                "  {}… {collapsed} completed tasks collapsed{}\n",
1232                c.dim, c.reset
1233            ));
1234        }
1235        out
1236    }
1237}
1238
1239/// Recursive source-order walk capturing depth, markers, detail, counts, and
1240/// the attention list.
1241#[allow(clippy::too_many_arguments)]
1242fn collect_rows(
1243    tasks: &[rhei_core::ast::Task],
1244    depth: usize,
1245    machines: &rhei_validator::MachineSet,
1246    activity: &HashMap<String, TaskActivity>,
1247    halt_causes: &HashMap<String, HaltCause>,
1248    rows: &mut Vec<TaskRow>,
1249    attention: &mut Vec<AttentionRow>,
1250    waiting: &mut Vec<AttentionRow>,
1251    counts: &mut std::collections::BTreeMap<String, (usize, Marker)>,
1252) {
1253    for task in tasks {
1254        let machine = machines.for_task(&task.id);
1255        let state = normalized_state_name(task.state.as_str(), machine);
1256        let id = task.id.to_string();
1257        let marker = marker_for_task(&id, &state, machine, halt_causes);
1258
1259        let entry = counts.entry(state.clone()).or_insert((0, marker));
1260        entry.0 += 1;
1261
1262        let detail = task_detail(&id, &state, marker, halt_causes, activity);
1263        // §FS-rhei-run-report.3.1: a parent held open by its own subtree is not
1264        // halted work, so it is counted nowhere the descendant is already
1265        // counted — see [`is_calm_parent`].
1266        if marker.needs_attention() && !is_calm_parent(&id, &state, machine, halt_causes) {
1267            let (reason, next) = attention_reason(marker, &id, &state, halt_causes);
1268            let row = AttentionRow {
1269                id: id.clone(),
1270                state: state.clone(),
1271                reason,
1272                next,
1273                is_gate: marker == Marker::Gate,
1274            };
1275            // A held ticket is someone else's turn, not a human's: it belongs
1276            // under Waiting, where it explains itself without diluting the rows
1277            // a person has to act on. §FS-rhei-supervision.3.4
1278            if matches!(halt_causes.get(&id), Some(HaltCause::HeldBySupervisor { .. })) {
1279                waiting.push(row);
1280            } else {
1281                attention.push(row);
1282            }
1283        }
1284
1285        rows.push(TaskRow { depth, id, state, marker, detail });
1286        collect_rows(
1287            &task.children,
1288            depth + 1,
1289            machines,
1290            activity,
1291            halt_causes,
1292            rows,
1293            attention,
1294            waiting,
1295            counts,
1296        );
1297    }
1298}
1299
1300/// Build the detail column for a task row: driver + timing when the run spawned
1301/// work, otherwise a short reason for halted tasks. §FS-rhei-run-report.3.2
1302fn task_detail(
1303    id: &str,
1304    state: &str,
1305    marker: Marker,
1306    halt_causes: &HashMap<String, HaltCause>,
1307    activity: &HashMap<String, TaskActivity>,
1308) -> Option<String> {
1309    if let Some(act) = activity.get(id) {
1310        let cost = act
1311            .accounting
1312            .as_ref()
1313            .map(|accounting| format!(" · {}", format_summary_cost(accounting)))
1314            .unwrap_or_default();
1315        if let Some(driver) = act.driver {
1316            let label = if act.invocations > 1 {
1317                format!("{driver}×{}", act.invocations)
1318            } else {
1319                driver.to_string()
1320            };
1321            return Some(format!(
1322                "{label}  {}{}",
1323                format_duration_short(act.last_duration_ms),
1324                cost
1325            ));
1326        }
1327        if !cost.is_empty() {
1328            return Some(cost.trim_start_matches(" · ").to_string());
1329        }
1330    }
1331    match marker {
1332        Marker::Gate | Marker::Attention => {
1333            Some(attention_reason(marker, id, state, halt_causes).0)
1334        }
1335        _ => None,
1336    }
1337}
1338
1339fn build_task_accounting_rows(
1340    rows: &[TaskRow],
1341    activity: &HashMap<String, TaskActivity>,
1342) -> Vec<TaskAccountingRow> {
1343    rows.iter()
1344        .filter_map(|row| {
1345            let accounting = activity.get(&row.id)?.accounting.as_ref()?;
1346            Some(TaskAccountingRow {
1347                task: row.id.clone(),
1348                cost: format_summary_cost(accounting),
1349                total: format_dimension_value(&accounting.total),
1350                input: format_dimension_value(&accounting.input_total),
1351                input_cached: format_dimension_value(&accounting.input_cached_read),
1352                output: format_dimension_value(&accounting.output_total),
1353                output_cached: format_dimension_value(&accounting.output_cached_read),
1354                coverage: format!("{:?}", accounting.coverage),
1355            })
1356        })
1357        .collect()
1358}
1359
1360/// The reason and next action for a halted task.
1361///
1362/// The plan-wide classification knows whether the
1363/// ticket is claimed, waiting on a prior, or manual-only, and names the command
1364/// that clears each. Reporting all three as "stalled in non-terminal state <s>"
1365/// and advising "inspect logs or mark the task cancelled" told an operator to
1366/// cancel work that only needed a claim released, and pointed at logs a run
1367/// that spawned nothing never wrote. The generic pair remains the fallback for
1368/// a ticket the classifier does not reach.
1369// §FS-rhei-run-report.3.1
1370fn attention_reason(
1371    marker: Marker,
1372    id: &str,
1373    state: &str,
1374    halt_causes: &HashMap<String, HaltCause>,
1375) -> (String, String) {
1376    if let Some(cause) = halt_causes.get(id) {
1377        return cause.describe(id, state);
1378    }
1379    match marker {
1380        Marker::Gate => HaltCause::Gate.describe(id, state),
1381        _ => HaltCause::Stalled.describe(id, state),
1382    }
1383}
1384
1385/// The run's one-line outcome.
1386///
1387/// `interrupted` outranks everything else: the operator stopped the run, so
1388/// whatever the plan looks like now is a snapshot of work in progress and not a
1389/// verdict on it. Reading it as "stopped for human attention" told the operator
1390/// to go and act on tickets whose only problem was that they were interrupted.
1391///
1392/// The caller passes the *signal* reading of the stop token, not the bare one:
1393/// a run unwinding from an error raises it too, on its way to tearing down the
1394/// groups it still owned, and that run has a verdict of its own. It passes the
1395/// reading taken where its loop ended, not one taken here: a run that had
1396/// already finished when the signal arrived was not cut short by it.
1397// §FS-rhei-run-report.3.1 §FS-rhei-run.3.2
1398fn result_phrase(
1399    attention: &[AttentionRow],
1400    rows: &[TaskRow],
1401    no_work: bool,
1402    advanced_without_work: bool,
1403    // Named for the reading, not for the function that takes it: spelling this
1404    // `interrupted_by_signal` put the free function of that name in scope
1405    // beside a parameter shadowing it, and made "ask the token here" — the one
1406    // thing the paragraph above forbids — a one-character edit that compiles.
1407    cut_short_by_signal: bool,
1408) -> String {
1409    let all_terminal_success =
1410        rows.iter().all(|r| matches!(r.marker, Marker::Done | Marker::TerminalAtStart));
1411    if cut_short_by_signal {
1412        "interrupted — re-run to continue".to_string()
1413    } else if !attention.is_empty() {
1414        // Gated and blocked tasks both halt the run for a human; the report and
1415        // tree carry the per-task distinction. §FS-rhei-run-report.6
1416        "stopped for human attention".to_string()
1417    } else if all_terminal_success && no_work && advanced_without_work {
1418        // A run that advanced tasks while spawning nothing must not read like a
1419        // fast successful run — name the absence of work. §FS-rhei-run-report.3.3
1420        "completed — no work spawned".to_string()
1421    } else if all_terminal_success {
1422        "completed".to_string()
1423    } else {
1424        "finished".to_string()
1425    }
1426}
1427
1428/// Escape a value for a Markdown table cell: pipes would split the column and
1429/// newlines would break the row, so both are neutralized.
1430fn md_cell(value: &str) -> String {
1431    value.replace('|', "\\|").replace('\n', " ")
1432}
1433
1434/// Render an invocation cell. `"<driver> / <log>"` becomes `<driver> / [log](log)`
1435/// so the log is a relative link; anything else (notably `none`) is escaped text.
1436/// §FS-rhei-run-report.7
1437fn md_link_or_text(value: &str) -> String {
1438    match value.split_once(" / ") {
1439        Some((label, path)) => format!("{} / [{}]({})", md_cell(label), path, path),
1440        None => md_cell(value),
1441    }
1442}
1443
1444/// Render a path relative to the workspace root with forward slashes, so report
1445/// links survive the workspace being moved, committed, or pasted into an issue.
1446/// §FS-rhei-run-report.1
1447fn relativize(path: &std::path::Path, root: &std::path::Path) -> String {
1448    let rel = path.strip_prefix(root).unwrap_or(path);
1449    rel.components()
1450        .map(|c| c.as_os_str().to_string_lossy())
1451        .collect::<Vec<_>>()
1452        .join("/")
1453}
1454
1455/// A short reason string for a spawned invocation, from its outcome and exit.
1456fn ledger_outcome_reason(outcome: &LedgerOutcome, exit_code: Option<i32>) -> String {
1457    match outcome {
1458        LedgerOutcome::Completed => match exit_code {
1459            Some(0) | None => "exit 0".to_string(),
1460            Some(code) => format!("exit {code}"),
1461        },
1462        LedgerOutcome::Failed(msg) => {
1463            let msg = msg.lines().next().unwrap_or("").trim();
1464            match exit_code {
1465                Some(code) if msg.is_empty() => format!("failed, exit {code}"),
1466                Some(code) => format!("exit {code}: {msg}"),
1467                None if msg.is_empty() => "failed".to_string(),
1468                None => format!("failed: {msg}"),
1469            }
1470        }
1471        LedgerOutcome::Cancelled => "cancelled".to_string(),
1472        LedgerOutcome::TimedOut => "timed out".to_string(),
1473        // Not a verdict on the ticket: the run stopped the worker. §FS-rhei-run.3.2
1474        LedgerOutcome::Interrupted => "interrupted".to_string(),
1475    }
1476}
1477
1478/// Assemble the Transition Ledger in source order: spawned rows from the event
1479/// stream, plus synthesized callback / terminal-at-start / blocked rows for tasks
1480/// that emit no slot events. §FS-rhei-run-report.4
1481#[allow(clippy::too_many_arguments)]
1482fn build_ledger(
1483    rows: &[TaskRow],
1484    attention: &[AttentionRow],
1485    halt_causes: &HashMap<String, HaltCause>,
1486    records: &[LedgerRecord],
1487    initial_states: &HashMap<String, String>,
1488    machines: &rhei_validator::MachineSet,
1489    workspace_root: &std::path::Path,
1490) -> Vec<LedgerEntry> {
1491    let attention_by_id: HashMap<&str, &AttentionRow> =
1492        attention.iter().map(|a| (a.id.as_str(), a)).collect();
1493    let mut ledger = Vec::new();
1494    for row in rows {
1495        let task_records: Vec<&LedgerRecord> =
1496            records.iter().filter(|r| r.task == row.id).collect();
1497        if !task_records.is_empty() {
1498            for rec in &task_records {
1499                let log = relativize(&rec.log_path, workspace_root);
1500                ledger.push(LedgerEntry {
1501                    task: row.id.clone(),
1502                    from: rec.from.clone(),
1503                    to: rec.to.clone(),
1504                    driver: rec.driver,
1505                    invocation: format!("{} / {}", rec.driver, log),
1506                    reason: ledger_outcome_reason(&rec.outcome, rec.exit_code),
1507                });
1508            }
1509            // If the task ended in a terminal-success state past the last spawned
1510            // transition, a callback or transition rule carried it the rest of the
1511            // way — record that advance so the ledger reaches the final state.
1512            let last_to = task_records.last().map(|r| r.to.as_str());
1513            if matches!(row.marker, Marker::Done | Marker::TerminalAtStart)
1514                && last_to != Some(row.state.as_str())
1515            {
1516                ledger.push(LedgerEntry {
1517                    task: row.id.clone(),
1518                    from: last_to.unwrap_or("").to_string(),
1519                    to: row.state.clone(),
1520                    driver: "callback-only",
1521                    invocation: "none".to_string(),
1522                    reason: "advanced without spawning work".to_string(),
1523                });
1524            }
1525            continue;
1526        }
1527
1528        // No invocation ran for this task this run — classify why it sits where
1529        // it does from the plan and the initial-state snapshot.
1530        let initial = initial_states.get(&row.id).map(String::as_str);
1531        if row.marker == Marker::TerminalAtStart {
1532            ledger.push(LedgerEntry {
1533                task: row.id.clone(),
1534                from: row.state.clone(),
1535                to: "-".to_string(),
1536                driver: "terminal-at-start",
1537                invocation: "none".to_string(),
1538                reason: "already terminal".to_string(),
1539            });
1540        } else if matches!(row.marker, Marker::Attention | Marker::Gate)
1541            // §FS-rhei-run-report.4: the parent is not a blocked row of its own
1542            // — see [`is_calm_parent`].
1543            && !is_calm_parent(
1544                &row.id,
1545                &row.state,
1546                machines.for_task(&parse_task_id(&row.id)),
1547                halt_causes,
1548            )
1549        {
1550            let reason = attention_by_id
1551                .get(row.id.as_str())
1552                .map(|a| a.reason.clone())
1553                .unwrap_or_else(|| format!("stalled in non-terminal state {}", row.state));
1554            ledger.push(LedgerEntry {
1555                task: row.id.clone(),
1556                from: row.state.clone(),
1557                to: "-".to_string(),
1558                driver: "blocked",
1559                invocation: "none".to_string(),
1560                reason,
1561            });
1562        } else if initial != Some(row.state.as_str()) {
1563            // Advanced to a new state without spawning a subprocess: callbacks,
1564            // transition rules, or already-present outputs carried it forward.
1565            ledger.push(LedgerEntry {
1566                task: row.id.clone(),
1567                from: initial.unwrap_or("").to_string(),
1568                to: row.state.clone(),
1569                driver: "callback-only",
1570                invocation: "none".to_string(),
1571                reason: "advanced without spawning work".to_string(),
1572            });
1573        } else if is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id))) {
1574            ledger.push(LedgerEntry {
1575                task: row.id.clone(),
1576                from: row.state.clone(),
1577                to: "-".to_string(),
1578                driver: "terminal-at-start",
1579                invocation: "none".to_string(),
1580                reason: "already terminal".to_string(),
1581            });
1582        }
1583    }
1584    ledger
1585}
1586
1587/// Collect spawned agents/programs for the Invocations section. §FS-rhei-run-report.7
1588fn build_invocations(
1589    records: &[LedgerRecord],
1590    workspace_root: &std::path::Path,
1591) -> Vec<InvocationRow> {
1592    records
1593        .iter()
1594        .map(|rec| InvocationRow {
1595            driver: rec.driver,
1596            task: rec.task.clone(),
1597            exit: match (&rec.outcome, rec.exit_code) {
1598                (LedgerOutcome::Cancelled, _) => "cancelled".to_string(),
1599                (LedgerOutcome::TimedOut, _) => "timed out".to_string(),
1600                (LedgerOutcome::Interrupted, _) => "interrupted".to_string(),
1601                (_, Some(code)) => format!("exit {code}"),
1602                (_, None) => "—".to_string(),
1603            },
1604            duration_ms: rec.duration_ms,
1605            log: relativize(&rec.log_path, workspace_root),
1606        })
1607        .collect()
1608}
1609
1610fn format_work(agents: u32, programs: u32, callback_only: u32) -> String {
1611    let mut parts = vec![format!("{agents} agents"), format!("{programs} programs")];
1612    if callback_only > 0 {
1613        parts.push(format!("{callback_only} callback-only"));
1614    }
1615    parts.join(" · ")
1616}
1617
1618fn marker_order(marker: Marker) -> u8 {
1619    match marker {
1620        Marker::Done => 0,
1621        Marker::Gate => 1,
1622        Marker::Attention => 2,
1623        Marker::Cancelled => 3,
1624        Marker::TerminalAtStart => 4,
1625    }
1626}
1627
1628fn format_duration_short(ms: u64) -> String {
1629    if ms < 60_000 {
1630        format!("{:.1}s", ms as f64 / 1000.0)
1631    } else {
1632        format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1000)
1633    }
1634}
1635
1636fn format_duration_long(d: std::time::Duration) -> String {
1637    let secs = d.as_secs();
1638    if secs < 60 {
1639        format!("{:.1}s", d.as_secs_f64())
1640    } else {
1641        format!("{}m{:02}s", secs / 60, secs % 60)
1642    }
1643}
1644
1645/// ANSI palette gated by a single `color` flag, so the renderer stays one code
1646/// path for both colored and plain output.
1647struct Palette {
1648    color: bool,
1649    reset: &'static str,
1650    bold: &'static str,
1651    dim: &'static str,
1652    red: &'static str,
1653}
1654
1655impl Palette {
1656    fn new(color: bool) -> Self {
1657        Self {
1658            color,
1659            reset: if color { RESET } else { "" },
1660            bold: if color { BOLD } else { "" },
1661            dim: if color { DIM } else { "" },
1662            red: if color { RED } else { "" },
1663        }
1664    }
1665
1666    fn color(&self, code: &'static str) -> &'static str {
1667        if self.color {
1668            code
1669        } else {
1670            ""
1671        }
1672    }
1673
1674    fn colored(&self, code: &'static str, text: &str) -> String {
1675        if self.color {
1676            format!("{code}{text}{RESET}")
1677        } else {
1678            text.to_string()
1679        }
1680    }
1681
1682    fn result_color(&self, result: &str) -> &'static str {
1683        if !self.color {
1684            return "";
1685        }
1686        if result.starts_with("stopped — ") {
1687            RED
1688        } else if result.starts_with("interrupted") {
1689            // Not red: an interrupted run is a run the operator stopped, not a
1690            // run that went wrong. §FS-rhei-run-report.3.1
1691            YELLOW
1692        } else if result.starts_with("stopped") {
1693            YELLOW
1694        } else if result == "completed" {
1695            GREEN
1696        } else {
1697            ""
1698        }
1699    }
1700}
1701
1702#[cfg(test)]
1703mod run_summary_tests {
1704    use super::*;
1705
1706    fn machine() -> rhei_validator::StateMachine {
1707        rhei_validator::StateMachine::builtin_default()
1708    }
1709
1710    /// Parse a tiny plan whose tasks carry the given `(id, state)` pairs.
1711    fn report(tasks: &[(&str, &str)]) -> RunSummaryReport {
1712        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1713        for (id, state) in tasks {
1714            md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1715        }
1716        let rhei = rhei_core::parse(&md).expect("plan parses");
1717        RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), test_stats(), "plan.rhei.md")
1718    }
1719
1720    /// `RunStats` with non-zero spawn counts and empty run metadata, for the
1721    /// renderer tests that do not exercise the durable header.
1722    fn test_stats() -> RunStats {
1723        RunStats {
1724            agents_spawned: 2,
1725            programs_spawned: 3,
1726            callback_only: 0,
1727            duration: Some(std::time::Duration::from_secs(5)),
1728            dashboard: None,
1729            run_id: "abc123".to_string(),
1730            started_at: Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_749_115_351)),
1731            workspace_root: std::path::PathBuf::from("examples/test"),
1732            command: "rhei run .".to_string(),
1733            parallel: 4,
1734            mode: "agent",
1735            initial_states: HashMap::new(),
1736            dry_run: false,
1737            interrupted: false,
1738        }
1739    }
1740
1741    #[test]
1742    fn markers_classify_by_state_class() {
1743        let m = machine();
1744        assert_eq!(classify_marker("completed", &m), Marker::Done);
1745        assert_eq!(classify_marker("blocked", &m), Marker::Attention);
1746        assert_eq!(classify_marker("cancelled", &m), Marker::Cancelled);
1747    }
1748
1749    /// A parent halted only because its own subtree is open is the eligibility
1750    /// rule working, so it reads as a calm pause. Classifying by state alone
1751    /// turned every ancestor of one gated leaf into its own red Attention row.
1752    // §FS-rhei-run-report.3.2
1753    #[test]
1754    fn a_parent_waiting_on_its_subtree_reads_as_a_calm_pause() {
1755        let m = machine();
1756        let mut causes: HashMap<String, HaltCause> = HashMap::new();
1757        causes.insert(
1758            "plan.1".to_string(),
1759            HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (human-gate)".to_string() },
1760        );
1761        causes.insert("plan.2".to_string(), HaltCause::Stalled);
1762
1763        // Same state, same machine: only the halt cause separates the two.
1764        assert_eq!(classify_marker("pending", &m), Marker::Attention);
1765        assert_eq!(marker_for_task("plan.1", "pending", &m, &causes), Marker::Gate);
1766        assert_eq!(marker_for_task("plan.2", "pending", &m, &causes), Marker::Attention);
1767        assert_eq!(marker_for_task("plan.3", "pending", &m, &causes), Marker::Attention);
1768
1769        // The reason still names the descendants, and the row still counts as
1770        // a gate rather than as something broken.
1771        let (reason, _) = attention_reason(Marker::Gate, "plan.1", "pending", &causes);
1772        assert!(
1773            reason.contains("waiting on open descendant Task plan.1.1 (human-gate)"),
1774            "{reason}"
1775        );
1776    }
1777
1778    /// One gated leaf under three ancestors is one thing needing a human, so
1779    /// the report counts it once. Treating each ancestor as halted work of its
1780    /// own gave four Attention rows, `4 gated`, `could not advance | 4`, and
1781    /// four blocked ledger rows for a single decision — and the topmost
1782    /// parent's reason text repeated the whole transitive subtree.
1783    // §FS-rhei-run-report.3.1 §FS-rhei-run-report.4 §FS-rhei-plan-language.3
1784    #[test]
1785    fn one_gate_under_three_ancestors_is_counted_once() {
1786        let rhei = rhei_core::parse(
1787            r#"# Rhei: Deep Subtree
1788---
1789structure:
1790  maxLevels: 4
1791---
1792
1793## Tasks
1794
1795### Task 1: Top
1796**State:** work
1797
1798#### Task 1.1: Middle
1799**State:** work
1800
1801##### Task 1.1.1: Inner
1802**State:** work
1803
1804###### Task 1.1.1.1: Gated leaf
1805**State:** human-gate
1806"#,
1807        )
1808        .expect("plan parses");
1809        let machine = rhei_validator::StateMachine::from_yaml_str(
1810            r#"name: t
1811version: 1
1812states:
1813  work:
1814    initial: true
1815    description: work
1816  human-gate:
1817    description: awaiting a human
1818    gating: true
1819  done:
1820    description: terminal
1821    final: true
1822transitions:
1823  - from: work
1824    to: done
1825  - from: human-gate
1826    to: done
1827"#,
1828        )
1829        .expect("valid state machine");
1830        let report = RunSummaryReport::build(
1831            &rhei,
1832            &rhei_validator::MachineSet::single(machine),
1833            &SummarySink::new(),
1834            test_stats(),
1835            "plan.rhei.md",
1836        );
1837
1838        assert_eq!(
1839            report.attention.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
1840            vec!["1.1.1.1"],
1841            "only the gate itself is halted work"
1842        );
1843
1844        let tty = report.render_tty(false);
1845        assert!(tty.contains("Attention  1 gated · 0 blocked"), "{tty}");
1846
1847        let markdown = report.render_markdown();
1848        assert!(markdown.contains("| could not advance | 1 |"), "{markdown}");
1849        assert_eq!(
1850            report.ledger.iter().filter(|e| e.driver == "blocked").count(),
1851            1,
1852            "one blocked ledger row, not one per ancestor"
1853        );
1854
1855        // The ancestors stay visible in the tree, calm and specific about what
1856        // holds them. §FS-rhei-run-report.3.2
1857        for id in ["1", "1.1", "1.1.1"] {
1858            let row = report.rows.iter().find(|r| r.id == id).expect("row present");
1859            assert_eq!(row.marker, Marker::Gate, "{id}");
1860            assert!(
1861                row.detail.as_deref().is_some_and(|d| d.contains("waiting on open descendant")),
1862                "{id}: {:?}",
1863                row.detail
1864            );
1865        }
1866    }
1867
1868    /// A parent that is itself blocked keeps its own attention marker: that is
1869    /// wrong independently of whatever its children are doing.
1870    // §FS-rhei-run-report.3.2
1871    #[test]
1872    fn a_failed_parent_keeps_its_attention_marker() {
1873        let m = machine();
1874        let mut causes: HashMap<String, HaltCause> = HashMap::new();
1875        causes.insert(
1876            "plan.1".to_string(),
1877            HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (pending)".to_string() },
1878        );
1879        assert_eq!(marker_for_task("plan.1", "blocked", &m, &causes), Marker::Attention);
1880    }
1881
1882    #[test]
1883    fn plain_render_lists_every_task_with_state() {
1884        let r = report(&[("1", "completed"), ("2", "blocked")]);
1885        let out = r.render_tty(false);
1886        assert!(out.contains("Run Report"), "{out}");
1887        assert!(out.contains("Test Plan"), "{out}");
1888        assert!(out.contains("completed"), "{out}");
1889        assert!(out.contains("blocked"), "{out}");
1890        // No ANSI escapes when color is disabled.
1891        assert!(!out.contains('\x1b'), "{out}");
1892    }
1893
1894    #[test]
1895    fn attention_block_surfaces_blocked_tasks() {
1896        let r = report(&[("1", "completed"), ("2", "blocked")]);
1897        let out = r.render_tty(false);
1898        assert!(out.contains("Attention"), "{out}");
1899        assert!(out.contains("1 blocked"), "{out}");
1900        assert!(out.contains("stopped for human attention"), "{out}");
1901    }
1902
1903    #[test]
1904    fn all_completed_reads_as_completed() {
1905        let r = report(&[("1", "completed"), ("2", "completed")]);
1906        let out = r.render_tty(false);
1907        assert!(out.contains("completed"), "{out}");
1908        assert!(!out.contains("Attention"), "{out}");
1909    }
1910
1911    #[test]
1912    fn color_render_emits_ansi() {
1913        let r = report(&[("1", "blocked")]);
1914        let out = r.render_tty(true);
1915        assert!(out.contains('\x1b'), "expected ANSI escapes");
1916    }
1917
1918    #[test]
1919    fn duration_formats_short_and_long() {
1920        assert_eq!(format_duration_short(200), "0.2s");
1921        assert_eq!(format_duration_short(8_100), "8.1s");
1922        assert_eq!(format_duration_short(65_000), "1m05s");
1923        assert_eq!(format_duration_long(std::time::Duration::from_secs(724)), "12m04s");
1924    }
1925
1926    /// Build a report from `(id, state)` pairs and a custom `RunStats`, used by
1927    /// the durable-report tests that vary spawn counts and initial states.
1928    fn report_with(tasks: &[(&str, &str)], stats: RunStats) -> RunSummaryReport {
1929        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1930        for (id, state) in tasks {
1931            md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1932        }
1933        let rhei = rhei_core::parse(&md).expect("plan parses");
1934        RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), stats, "plan.rhei.md")
1935    }
1936
1937    #[test]
1938    fn markdown_report_has_all_sections() {
1939        let r = report(&[("1", "completed"), ("2", "blocked")]);
1940        let md = r.render_markdown();
1941        assert!(md.starts_with("# Run Report: Test Plan"), "{md}");
1942        assert!(md.contains("Run: 2025-"), "header carries the ISO start: {md}");
1943        assert!(md.contains("| Final states | Count |"), "{md}");
1944        assert!(md.contains("| Activity | Count |"), "{md}");
1945        assert!(md.contains("## Attention"), "{md}");
1946        assert!(md.contains("## Transition Ledger"), "{md}");
1947        assert!(md.contains("## Task Final States"), "{md}");
1948    }
1949
1950    #[test]
1951    fn run_id_is_stable_for_a_given_start() {
1952        let t = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(1_749_115_351_123_456);
1953        assert_eq!(short_run_id(t), short_run_id(t));
1954        assert_eq!(short_run_id(t).len(), 6);
1955    }
1956
1957    #[test]
1958    fn no_work_run_that_advanced_reads_differently() {
1959        // Every task ended completed, nothing spawned, and a task moved off its
1960        // non-terminal start — the report must not look like fast agent work.
1961        // §FS-rhei-run-report.3.3
1962        let mut initial = HashMap::new();
1963        initial.insert("1".to_string(), "queued".to_string());
1964        let stats = RunStats {
1965            agents_spawned: 0,
1966            programs_spawned: 0,
1967            callback_only: 1,
1968            initial_states: initial,
1969            ..test_stats()
1970        };
1971        let r = report_with(&[("1", "completed")], stats);
1972        assert_eq!(r.result, "completed — no work spawned");
1973        let md = r.render_markdown();
1974        assert!(md.contains("No agent or program ran"), "{md}");
1975        // The advance with no invocation is a callback-only ledger row.
1976        assert!(md.contains("| 1 | queued | completed | callback-only |"), "{md}");
1977    }
1978
1979    #[test]
1980    fn terminal_at_start_task_is_marked_calm() {
1981        let mut initial = HashMap::new();
1982        initial.insert("done".to_string(), "completed".to_string());
1983        let stats = RunStats { initial_states: initial, ..test_stats() };
1984        let r = report_with(&[("done", "completed")], stats);
1985        assert_eq!(r.terminal_at_start, 1);
1986        let md = r.render_markdown();
1987        assert!(md.contains("terminal at start"), "{md}");
1988        // It is a terminal-at-start ledger row, not an invocation.
1989        assert!(md.contains("| done | completed | - | terminal-at-start |"), "{md}");
1990    }
1991
1992    #[test]
1993    fn write_to_runtime_emits_latest_and_history() {
1994        let dir = tempfile::tempdir().expect("tmpdir");
1995        let runtime = dir.path().join("runtime");
1996        let stats =
1997            RunStats { workspace_root: dir.path().to_path_buf(), ..test_stats() };
1998        let mut r = report_with(&[("1", "completed")], stats);
1999        r.write_to_runtime(&runtime).expect("write report");
2000        assert!(runtime.join("run-report.md").exists());
2001        assert_eq!(r.report_path.as_deref(), Some("runtime/run-report.md"));
2002        let history = std::fs::read_dir(runtime.join("run-reports"))
2003            .expect("history dir")
2004            .filter_map(Result::ok)
2005            .count();
2006        assert_eq!(history, 1, "one timestamped history entry written");
2007    }
2008
2009    /// The result follows the reading the run took when its loop ended, not
2010    /// the process-wide token at report time: a signal that arrives after the
2011    /// run finished — while the TUI is parked on its finished screen — leaves
2012    /// the run its own result.
2013    // §FS-rhei-run.3.2 §FS-rhei-run-report.3.1
2014    #[test]
2015    fn a_signal_after_the_loop_finished_does_not_relabel_the_result() {
2016        let finished = report_with(&[("1", "completed")], test_stats());
2017        assert_eq!(finished.result, "completed");
2018        let cut_short =
2019            report_with(&[("1", "completed")], RunStats { interrupted: true, ..test_stats() });
2020        assert_eq!(cut_short.result, "interrupted — re-run to continue");
2021    }
2022
2023    #[test]
2024    fn dry_run_result_reads_as_preview() {
2025        let stats = RunStats { dry_run: true, ..test_stats() };
2026        let r = report_with(&[("1", "completed")], stats);
2027        assert_eq!(r.result, "dry run — no changes applied");
2028        assert!(r.render_markdown().contains("Result: dry run — no changes applied"));
2029    }
2030
2031    #[test]
2032    fn dashboard_pointer_gated_on_enabled_this_run() {
2033        let dir = tempfile::tempdir().expect("tmpdir");
2034        let runtime = dir.path().join("runtime");
2035        std::fs::create_dir_all(&runtime).unwrap();
2036        std::fs::write(runtime.join("dashboard.html"), "<html>").unwrap();
2037        // A stale dashboard from an earlier run must not be linked when the
2038        // dashboard was off this run.
2039        assert_eq!(frozen_dashboard_relative_path(false, &runtime, dir.path()), None);
2040        assert_eq!(
2041            frozen_dashboard_relative_path(true, &runtime, dir.path()).as_deref(),
2042            Some("runtime/dashboard.html"),
2043        );
2044    }
2045
2046    #[test]
2047    fn md_cell_escapes_pipes_and_newlines() {
2048        assert_eq!(md_cell("a|b"), "a\\|b");
2049        assert_eq!(md_cell("line1\nline2"), "line1 line2");
2050    }
2051
2052    /// A `SummarySink` carrying one spawned transition `from`→`to`.
2053    fn summary_with_spawn(task: &str, from: &str, to: &str, agent: bool) -> SummarySink {
2054        use rhei_tui::EventSink;
2055        let s = SummarySink::new();
2056        let log = std::path::PathBuf::from("runtime/logs/x.log");
2057        s.emit(rhei_tui::RunEvent::SlotAssigned {
2058            slot: 0,
2059            task: task.to_string(),
2060            from: from.to_string(),
2061            to: to.to_string(),
2062            agent: agent.then(|| "mock".to_string()),
2063            template_context: None,
2064            log_path: log.clone(),
2065            started_at: std::time::Instant::now(),
2066            wall_clock: std::time::SystemTime::now(),
2067        });
2068        s.emit(rhei_tui::RunEvent::SlotReleased {
2069            slot: 0,
2070            task: task.to_string(),
2071            from: from.to_string(),
2072            to: to.to_string(),
2073            log_path: log,
2074            outcome: rhei_tui::TaskOutcome::Completed,
2075            finished_at: std::time::Instant::now(),
2076            wall_clock: std::time::SystemTime::now(),
2077            exit_code: Some(0),
2078            duration_ms: 1_200,
2079        });
2080        s
2081    }
2082
2083    #[test]
2084    fn ledger_records_trailing_callback_advance_after_spawn() {
2085        // An agent ran build->review, then a callback carried review->completed
2086        // with no further spawn. The ledger must reach the final state.
2087        let summary = summary_with_spawn("1", "build", "review", true);
2088        let stats = RunStats { initial_states: HashMap::new(), ..test_stats() };
2089        let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
2090        md.push_str("### Task 1: Task 1\n**State:** completed\n\n");
2091        let rhei = rhei_core::parse(&md).expect("plan parses");
2092        let report = RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &summary, stats, "plan.rhei.md");
2093        let md = report.render_markdown();
2094        // The spawned agent row and the synthesized callback advance both appear.
2095        assert!(md.contains("| 1 | build | review | agent |"), "{md}");
2096        assert!(md.contains("| 1 | review | completed | callback-only |"), "{md}");
2097    }
2098}