1#[derive(Debug, Clone, Default)]
14struct TaskActivity {
15 driver: Option<&'static str>,
17 invocations: u32,
19 last_duration_ms: u64,
21 accounting: Option<rhei_tui::AccountingRunSummary>,
23 missing_outputs: Option<(String, Vec<String>)>,
29}
30
31#[derive(Debug, Clone)]
35struct LedgerRecord {
36 task: String,
37 from: String,
38 to: String,
39 driver: &'static str,
41 log_path: std::path::PathBuf,
42 exit_code: Option<i32>,
43 duration_ms: u64,
44 outcome: LedgerOutcome,
45}
46
47#[derive(Debug, Clone)]
50enum LedgerOutcome {
51 Completed,
52 Failed(String),
53 Cancelled,
54 TimedOut,
55 Interrupted,
58}
59
60pub struct SummarySink {
64 inner: Mutex<SummaryState>,
65}
66
67#[derive(Default)]
68struct SummaryState {
69 inflight: HashMap<u16, &'static str>,
71 tasks: HashMap<String, TaskActivity>,
73 ledger: Vec<LedgerRecord>,
75 usages: Vec<rhei_tui::UsageSummary>,
78 usage_by_task: HashMap<String, Vec<rhei_tui::UsageSummary>>,
80 accounting: Option<rhei_tui::AccountingRunSummary>,
82}
83
84impl SummarySink {
85 pub fn new() -> Self {
86 Self { inner: Mutex::new(SummaryState::default()) }
87 }
88
89 fn snapshot(&self) -> HashMap<String, TaskActivity> {
93 self.inner.lock().map(|state| state.tasks.clone()).unwrap_or_default()
94 }
95
96 fn ledger(&self) -> Vec<LedgerRecord> {
99 self.inner.lock().map(|state| state.ledger.clone()).unwrap_or_default()
100 }
101
102 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 rhei_tui::RunEvent::SlotAssigned { slot, task, agent, .. } => {
132 let driver = if agent.is_some() { "agent" } else { "program" };
133 state.inflight.insert(slot, driver);
134 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 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
200fn 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 let dry_run = stats.dry_run;
217 let plan_arg = plan_arg_for_help(input);
220 let mut report = RunSummaryReport::build(&loaded.rhei, machines, summary, stats, &plan_arg);
221 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 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 if stdout_carries_json_records() {
237 eprintln!("Report: {report_path}");
238 } else {
239 println!("Report: {report_path}");
240 }
241 }
242}
243
244fn 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
258fn 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
273fn 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
283fn 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
308struct 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 dry_run: bool,
326 summary: Option<std::sync::Arc<SummarySink>>,
328 armed: bool,
330}
331
332impl RunReportGuard<'_> {
333 fn disarm(&mut self) {
335 self.armed = false;
336 }
337}
338
339impl Drop for RunReportGuard<'_> {
340 fn drop(&mut self) {
341 if !self.armed || self.dry_run {
343 return;
344 }
345 let Some(summary) = self.summary.clone() else {
346 return;
347 };
348 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 interrupted: interrupted_by_signal(),
375 },
376 );
377 }
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383enum Marker {
384 Done,
386 Gate,
388 Attention,
390 Cancelled,
392 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 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 fn needs_attention(self) -> bool {
422 matches!(self, Marker::Gate | Marker::Attention)
423 }
424}
425
426fn state_is_failure(state: &str) -> bool {
428 matches!(state, "blocked" | "failed")
429}
430
431fn classify_marker(state: &str, machine: &rhei_validator::StateMachine) -> Marker {
435 match state {
436 _ 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
451fn marker_for_task(
462 id: &str,
463 state: &str,
464 machine: &rhei_validator::StateMachine,
465 halt_causes: &HashMap<String, HaltCause>,
466) -> Marker {
467 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
481fn 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
508struct TaskRow {
510 depth: usize,
511 id: String,
512 state: String,
513 marker: Marker,
514 detail: Option<String>,
516}
517
518struct AttentionRow {
521 id: String,
522 state: String,
523 reason: String,
524 next: String,
525 is_gate: bool,
529}
530
531pub 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 pub run_id: String,
540 pub started_at: Option<std::time::SystemTime>,
543 pub workspace_root: std::path::PathBuf,
545 pub command: String,
547 pub parallel: usize,
549 pub mode: &'static str,
551 pub initial_states: HashMap<String, String>,
555 pub dry_run: bool,
558 pub interrupted: bool,
563}
564
565struct LedgerEntry {
567 task: String,
568 from: String,
569 to: String,
571 driver: &'static str,
573 invocation: String,
575 reason: String,
576}
577
578struct InvocationRow {
580 driver: &'static str,
581 task: String,
582 exit: String,
584 duration_ms: u64,
585 log: String,
587}
588
589struct 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
601pub struct RunSummaryReport {
603 title: String,
604 result: String,
605 duration: Option<std::time::Duration>,
606 state_counts: Vec<(String, usize, Marker)>,
608 total_tasks: usize,
609 work: String,
610 accounting: Option<rhei_tui::AccountingRunSummary>,
611 attention: Vec<AttentionRow>,
612 waiting: Vec<AttentionRow>,
616 rows: Vec<TaskRow>,
617 dashboard: Option<String>,
618 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 report_path: Option<String>,
634 history_path: Option<String>,
635}
636
637const 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
645const BAR_WIDTH: usize = 24;
647const MAX_TASK_ROWS: usize = 40;
649const MAX_ATTENTION_ROWS: usize = 5;
651
652impl RunSummaryReport {
653 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 let ledger_records = summary.ledger();
666 let ledger = &ledger_records;
667
668 let halt_causes: HashMap<String, HaltCause> = classify_halted_tasks(
672 rhei,
673 machines,
674 &None,
675 &|id| activity.contains_key(id),
676 &|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 &|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 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 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 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 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 let result = if stats.dry_run {
758 "dry run — no changes applied".to_string()
759 } else {
760 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 pub fn render_tty(&self, color: bool) -> String {
812 let c = Palette::new(color);
813 let mut out = String::new();
814
815 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 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 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 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 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 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 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 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 pub fn render_markdown(&self) -> String {
931 let mut out = String::new();
932
933 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 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 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 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 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 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 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 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 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 fn workspace_root_path(&self) -> std::path::PathBuf {
1142 std::path::PathBuf::from(&self.workspace)
1143 }
1144
1145 fn render_bar(&self, c: &Palette) -> String {
1148 if self.total_tasks == 0 {
1149 return String::new();
1150 }
1151 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 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 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 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#[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 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 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
1300fn 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
1360fn 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
1385fn result_phrase(
1399 attention: &[AttentionRow],
1400 rows: &[TaskRow],
1401 no_work: bool,
1402 advanced_without_work: bool,
1403 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 "stopped for human attention".to_string()
1417 } else if all_terminal_success && no_work && advanced_without_work {
1418 "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
1428fn md_cell(value: &str) -> String {
1431 value.replace('|', "\\|").replace('\n', " ")
1432}
1433
1434fn 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
1444fn 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
1455fn 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 LedgerOutcome::Interrupted => "interrupted".to_string(),
1475 }
1476}
1477
1478#[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 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 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 && !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 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
1587fn 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
1645struct 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 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 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 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 #[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 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 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 #[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 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 #[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 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 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 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 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 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 #[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 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 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 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 assert!(md.contains("| 1 | build | review | agent |"), "{md}");
2096 assert!(md.contains("| 1 | review | completed | callback-only |"), "{md}");
2097 }
2098}