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 "cancelled" | "canceled" => return Marker::Cancelled,
437 _ if state_is_failure(state) => return Marker::Attention,
438 _ => {}
439 }
440 let def = machine.states.get(state);
441 if def.map(|d| d.gating).unwrap_or(false) {
442 Marker::Gate
443 } else if def.map(|d| d.terminal).unwrap_or(false) {
444 Marker::Done
445 } else {
446 Marker::Attention
447 }
448}
449
450fn marker_for_task(
461 id: &str,
462 state: &str,
463 machine: &rhei_validator::StateMachine,
464 halt_causes: &HashMap<String, HaltCause>,
465) -> Marker {
466 if is_calm_parent(id, state, machine, halt_causes) {
467 return Marker::Gate;
468 }
469 classify_marker(state, machine)
470}
471
472fn is_calm_parent(
489 id: &str,
490 state: &str,
491 machine: &rhei_validator::StateMachine,
492 halt_causes: &HashMap<String, HaltCause>,
493) -> bool {
494 classify_marker(state, machine) == Marker::Attention
495 && !state_is_failure(state)
496 && matches!(halt_causes.get(id), Some(HaltCause::WaitingOnDescendants { .. }))
497}
498
499struct TaskRow {
501 depth: usize,
502 id: String,
503 state: String,
504 marker: Marker,
505 detail: Option<String>,
507}
508
509struct AttentionRow {
512 id: String,
513 state: String,
514 reason: String,
515 next: String,
516 is_gate: bool,
520}
521
522pub struct RunStats {
524 pub agents_spawned: u32,
525 pub programs_spawned: u32,
526 pub callback_only: u32,
527 pub duration: Option<std::time::Duration>,
528 pub dashboard: Option<String>,
529 pub run_id: String,
531 pub started_at: Option<std::time::SystemTime>,
534 pub workspace_root: std::path::PathBuf,
536 pub command: String,
538 pub parallel: usize,
540 pub mode: &'static str,
542 pub initial_states: HashMap<String, String>,
546 pub dry_run: bool,
549 pub interrupted: bool,
554}
555
556struct LedgerEntry {
558 task: String,
559 from: String,
560 to: String,
562 driver: &'static str,
564 invocation: String,
566 reason: String,
567}
568
569struct InvocationRow {
571 driver: &'static str,
572 task: String,
573 exit: String,
575 duration_ms: u64,
576 log: String,
578}
579
580struct TaskAccountingRow {
582 task: String,
583 cost: String,
584 total: String,
585 input: String,
586 input_cached: String,
587 output: String,
588 output_cached: String,
589 coverage: String,
590}
591
592pub struct RunSummaryReport {
594 title: String,
595 result: String,
596 duration: Option<std::time::Duration>,
597 state_counts: Vec<(String, usize, Marker)>,
599 total_tasks: usize,
600 work: String,
601 accounting: Option<rhei_tui::AccountingRunSummary>,
602 attention: Vec<AttentionRow>,
603 rows: Vec<TaskRow>,
604 dashboard: Option<String>,
605 run_id: String,
607 started_at: Option<std::time::SystemTime>,
608 workspace: String,
609 command: String,
610 parallel: usize,
611 mode: &'static str,
612 agents_spawned: u32,
613 programs_spawned: u32,
614 callback_only: u32,
615 terminal_at_start: usize,
616 ledger: Vec<LedgerEntry>,
617 invocations: Vec<InvocationRow>,
618 task_accounting: Vec<TaskAccountingRow>,
619 report_path: Option<String>,
621 history_path: Option<String>,
622}
623
624const RESET: &str = "\x1b[0m";
626const BOLD: &str = "\x1b[1m";
627const DIM: &str = "\x1b[2m";
628const RED: &str = "\x1b[31m";
629const GREEN: &str = "\x1b[32m";
630const YELLOW: &str = "\x1b[33m";
631
632const BAR_WIDTH: usize = 24;
634const MAX_TASK_ROWS: usize = 40;
636const MAX_ATTENTION_ROWS: usize = 5;
638
639impl RunSummaryReport {
640 pub fn build(
643 rhei: &rhei_core::ast::Rhei,
644 machines: &rhei_validator::MachineSet,
645 summary: &SummarySink,
646 stats: RunStats,
647 plan_arg: &str,
648 ) -> Self {
649 let activity = summary.snapshot();
650 let ledger_records = summary.ledger();
653 let ledger = &ledger_records;
654
655 let halt_causes: HashMap<String, HaltCause> = classify_halted_tasks(
659 rhei,
660 machines,
661 &None,
662 &|id| activity.contains_key(id),
663 &|id, state| {
667 activity
668 .get(id)
669 .and_then(|entry| entry.missing_outputs.as_ref())
670 .filter(|(stalled_in, entries)| stalled_in == state && !entries.is_empty())
671 .map(|(_, entries)| entries.clone())
672 },
673 &|id| {
677 stats.interrupted
678 && matches!(
679 ledger
680 .iter()
681 .rev()
682 .find(|record| record.task == id)
683 .map(|record| &record.outcome),
684 Some(LedgerOutcome::Interrupted)
685 )
686 },
687 plan_arg,
688 )
689 .into_iter()
690 .map(|(task, cause)| (task.id.to_string(), cause))
691 .collect();
692
693 let mut rows = Vec::new();
695 let mut attention = Vec::new();
696 let mut counts: std::collections::BTreeMap<String, (usize, Marker)> =
697 std::collections::BTreeMap::new();
698 collect_rows(
699 &rhei.tasks,
700 0,
701 machines,
702 &activity,
703 &halt_causes,
704 &mut rows,
705 &mut attention,
706 &mut counts,
707 );
708
709 let mut terminal_at_start = 0usize;
713 for row in &mut rows {
714 let was = stats.initial_states.get(&row.id).map(String::as_str);
715 let unchanged_terminal = was == Some(row.state.as_str())
716 && is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id)));
717 if unchanged_terminal {
718 terminal_at_start += 1;
719 if row.marker == Marker::Done {
722 row.marker = Marker::TerminalAtStart;
723 row.detail = Some("terminal at start".to_string());
724 }
725 }
726 }
727
728 let total_tasks = rows.len();
729
730 let mut state_counts: Vec<(String, usize, Marker)> =
732 counts.into_iter().map(|(state, (n, marker))| (state, n, marker)).collect();
733 state_counts.sort_by_key(|(_, _, marker)| marker_order(*marker));
734
735 let no_work = stats.agents_spawned == 0 && stats.programs_spawned == 0;
736 let advanced_without_work = rows.iter().any(|r| {
737 r.marker == Marker::Done
738 && stats.initial_states.get(&r.id).map(String::as_str) != Some(r.state.as_str())
739 });
740 let result = if stats.dry_run {
743 "dry run — no changes applied".to_string()
744 } else {
745 result_phrase(&attention, &rows, no_work, advanced_without_work, stats.interrupted)
748 };
749 let work = format_work(stats.agents_spawned, stats.programs_spawned, stats.callback_only);
750 let accounting = summary.accounting();
751 let task_accounting = build_task_accounting_rows(&rows, &activity);
752
753 let ledger_rows = build_ledger(
754 &rows,
755 &attention,
756 &halt_causes,
757 ledger,
758 &stats.initial_states,
759 machines,
760 &stats.workspace_root,
761 );
762 let invocations = build_invocations(ledger, &stats.workspace_root);
763
764 Self {
765 title: rhei.title.clone(),
766 result,
767 duration: stats.duration,
768 state_counts,
769 total_tasks,
770 work,
771 accounting,
772 attention,
773 rows,
774 dashboard: stats.dashboard,
775 run_id: stats.run_id,
776 started_at: stats.started_at,
777 workspace: stats.workspace_root.display().to_string(),
778 command: stats.command,
779 parallel: stats.parallel,
780 mode: stats.mode,
781 agents_spawned: stats.agents_spawned,
782 programs_spawned: stats.programs_spawned,
783 callback_only: stats.callback_only,
784 terminal_at_start,
785 ledger: ledger_rows,
786 invocations,
787 task_accounting,
788 report_path: None,
789 history_path: None,
790 }
791 }
792
793 pub fn render_tty(&self, color: bool) -> String {
796 let c = Palette::new(color);
797 let mut out = String::new();
798
799 let dur = self.duration.map(format_duration_long).unwrap_or_default();
801 out.push_str(&format!(
802 "\n{}Run Report{} {}{}{}",
803 c.bold, c.reset, c.bold, self.title, c.reset
804 ));
805 if !dur.is_empty() {
806 out.push_str(&format!(" {}{}{}", c.dim, dur, c.reset));
807 }
808 out.push('\n');
809 out.push_str(&format!(" {}{}{}\n\n", c.result_color(&self.result), self.result, c.reset));
810
811 out.push_str(" States ");
813 out.push_str(&self.render_bar(&c));
814 out.push_str(" ");
815 out.push_str(&self.render_state_labels(&c));
816 out.push('\n');
817 out.push_str(&format!(" Work {}\n", self.work));
818 if let Some(accounting) = &self.accounting {
819 out.push_str(&format!(
822 " Cost {} · Total {} · In {} · In cached {} · Out {} · Out cached {} · Coverage {:?}\n",
823 format_summary_cost(accounting),
824 format_dimension_value(&accounting.total),
825 format_dimension_value(&accounting.input_total),
826 format_dimension_value(&accounting.input_cached_read),
827 format_dimension_value(&accounting.output_total),
828 format_dimension_value(&accounting.output_cached_read),
829 accounting.coverage,
830 ));
831 }
832
833 if !self.attention.is_empty() {
835 let gated = self.attention.iter().filter(|a| a.is_gate).count();
836 let blocked = self.attention.len() - gated;
837 out.push_str(&format!(
838 "\n{}Attention{} {} gated · {} blocked\n",
839 c.bold, c.reset, gated, blocked
840 ));
841 for row in self.attention.iter().take(MAX_ATTENTION_ROWS) {
842 out.push_str(&format!(
843 " {}!{} {:<26} {}{:<11}{} {}\n",
844 c.red, c.reset, row.id, c.dim, row.state, c.reset, row.reason
845 ));
846 out.push_str(&format!(" {}→ {}{}\n", c.dim, row.next, c.reset));
847 }
848 if self.attention.len() > MAX_ATTENTION_ROWS {
849 out.push_str(&format!(
850 " {}… {} more in the report{}\n",
851 c.dim,
852 self.attention.len() - MAX_ATTENTION_ROWS,
853 c.reset
854 ));
855 }
856 }
857
858 out.push_str(&format!(
860 "\n{}Tasks{} {} tasks · source order\n",
861 c.bold, c.reset, self.total_tasks
862 ));
863 out.push_str(&self.render_tree(&c));
864
865 out.push('\n');
868 if let Some(report) = &self.report_path {
869 out.push_str(&format!("Report {report}\n"));
870 }
871 if let Some(history) = &self.history_path {
872 out.push_str(&format!("History {history}\n"));
873 }
874 if let Some(dashboard) = &self.dashboard {
875 out.push_str(&format!("Dashboard {dashboard}\n"));
876 }
877 let trailing_newline = out.ends_with('\n');
879 let mut trimmed = out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
880 if trailing_newline {
881 trimmed.push('\n');
882 }
883 trimmed
884 }
885
886 pub fn render_markdown(&self) -> String {
890 let mut out = String::new();
891
892 out.push_str(&format!("# Run Report: {}\n\n", self.title));
894 let when = self
895 .started_at
896 .map(format_iso8601_utc)
897 .map(|ts| format!("{ts} / {}", self.run_id))
898 .unwrap_or_else(|| self.run_id.clone());
899 out.push_str(&format!("Run: {when}\n"));
900 out.push_str(&format!("Workspace: {}\n", self.workspace));
901 out.push_str(&format!("Command: {}\n", self.command));
902 out.push_str(&format!("Mode: {} · parallel {}\n", self.mode, self.parallel));
903 if let Some(dur) = self.duration {
904 out.push_str(&format!("Duration: {}\n", format_duration_long(dur)));
905 }
906 out.push_str(&format!("Result: {}\n", self.result));
907 if let Some(dashboard) = &self.dashboard {
908 out.push_str(&format!("Dashboard: {dashboard}\n"));
909 }
910 out.push('\n');
911
912 out.push_str("| Final states | Count |\n| --- | ---: |\n");
915 for (state, n, _) in &self.state_counts {
916 out.push_str(&format!("| {state} | {n} |\n"));
917 }
918 out.push('\n');
919 let could_not_advance = self.attention.len();
920 out.push_str("| Activity | Count |\n| --- | ---: |\n");
921 out.push_str(&format!("| agent invocations | {} |\n", self.agents_spawned));
922 out.push_str(&format!("| program invocations | {} |\n", self.programs_spawned));
923 out.push_str(&format!("| callback-only transitions | {} |\n", self.callback_only));
924 out.push_str(&format!("| terminal at start | {} |\n", self.terminal_at_start));
925 out.push_str(&format!("| could not advance | {could_not_advance} |\n"));
926 out.push('\n');
927 if let Some(accounting) = &self.accounting {
928 out.push_str("| Accounting | Value |\n| --- | ---: |\n");
930 out.push_str(&format!("| cost | {} |\n", format_summary_cost(accounting)));
931 out.push_str(&format!(
932 "| total tokens | {} |\n",
933 format_dimension_value(&accounting.total)
934 ));
935 out.push_str(&format!(
936 "| input tokens | {} |\n",
937 format_dimension_value(&accounting.input_total)
938 ));
939 out.push_str(&format!(
940 "| input cached | {} |\n",
941 format_dimension_value(&accounting.input_cached_read)
942 ));
943 out.push_str(&format!(
944 "| output tokens | {} |\n",
945 format_dimension_value(&accounting.output_total)
946 ));
947 out.push_str(&format!(
948 "| output cached | {} |\n",
949 format_dimension_value(&accounting.output_cached_read)
950 ));
951 out.push_str(&format!("| coverage | {:?} |\n", accounting.coverage));
952 out.push('\n');
953 }
954 if self.agents_spawned == 0 && self.programs_spawned == 0 {
955 out.push_str(
956 "> No agent or program ran this run. Any task that advanced did so through \
957 callbacks, transition rules, or outputs that already existed — inspect the \
958 ledger below before assuming work was performed.\n\n",
959 );
960 }
961
962 if !self.attention.is_empty() {
964 out.push_str("## Attention\n\n");
965 out.push_str("| Task | State | Reason | Next action |\n| --- | --- | --- | --- |\n");
966 for a in &self.attention {
967 out.push_str(&format!(
968 "| {} | {} | {} | {} |\n",
969 md_cell(&a.id),
970 md_cell(&a.state),
971 md_cell(&a.reason),
972 md_cell(&a.next),
973 ));
974 }
975 out.push('\n');
976 }
977
978 out.push_str("## Transition Ledger\n\n");
980 out.push_str(
981 "| Task | From | To | Driver | Invocation | Reason |\n\
982 | --- | --- | --- | --- | --- | --- |\n",
983 );
984 for e in &self.ledger {
985 out.push_str(&format!(
986 "| {} | {} | {} | {} | {} | {} |\n",
987 e.task,
988 md_cell(&e.from),
989 md_cell(&e.to),
990 e.driver,
991 md_link_or_text(&e.invocation),
992 md_cell(&e.reason),
993 ));
994 }
995 out.push('\n');
996
997 out.push_str("## Task Final States\n\n");
999 for row in &self.rows {
1000 let indent = " ".repeat(row.depth);
1001 let detail = row.detail.as_deref().unwrap_or("");
1002 let detail = if detail.is_empty() {
1003 String::new()
1004 } else {
1005 format!(" — {detail}")
1006 };
1007 out.push_str(&format!(
1008 "{indent}- {} `{}` ({}){detail}\n",
1009 row.marker.glyph(),
1010 row.id,
1011 row.state,
1012 ));
1013 }
1014 out.push('\n');
1015
1016 if !self.task_accounting.is_empty() {
1017 out.push_str("## Task Costs\n\n");
1018 out.push_str(
1019 "| Task | Cost | Total | Input | Input cached | Output | Output cached | Coverage |\n\
1020 | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n",
1021 );
1022 for row in &self.task_accounting {
1023 out.push_str(&format!(
1024 "| {} | {} | {} | {} | {} | {} | {} | {} |\n",
1025 md_cell(&row.task),
1026 row.cost,
1027 row.total,
1028 row.input,
1029 row.input_cached,
1030 row.output,
1031 row.output_cached,
1032 row.coverage,
1033 ));
1034 }
1035 out.push('\n');
1036 }
1037
1038 if !self.invocations.is_empty() {
1040 out.push_str("## Invocations\n\n");
1041 out.push_str(
1042 "| Task | Driver | Exit | Duration | Log |\n| --- | --- | --- | --- | --- |\n",
1043 );
1044 for inv in &self.invocations {
1045 out.push_str(&format!(
1046 "| {} | {} | {} | {} | [{}]({}) |\n",
1047 inv.task,
1048 inv.driver,
1049 inv.exit,
1050 format_duration_short(inv.duration_ms),
1051 inv.log,
1052 inv.log,
1053 ));
1054 }
1055 out.push('\n');
1056 }
1057
1058 out
1059 }
1060
1061 pub fn write_to_runtime(&mut self, runtime_dir: &std::path::Path) -> std::io::Result<()> {
1065 let body = self.render_markdown();
1066 let latest = runtime_dir.join("run-report.md");
1067 let history_dir = runtime_dir.join("run-reports");
1068 std::fs::create_dir_all(&history_dir)?;
1069 let stamp = self
1070 .started_at
1071 .map(format_iso8601_utc)
1072 .map(|ts| ts.replace(':', "-"))
1073 .unwrap_or_else(|| "unknown".to_string());
1074 let history = history_dir.join(format!("{stamp}-{}.md", self.run_id));
1075 std::fs::write(&latest, &body)?;
1076 std::fs::write(&history, &body)?;
1077 self.report_path = Some(relativize(&latest, &self.workspace_root_path()));
1078 self.history_path = Some(relativize(&history, &self.workspace_root_path()));
1079 Ok(())
1080 }
1081
1082 fn workspace_root_path(&self) -> std::path::PathBuf {
1084 std::path::PathBuf::from(&self.workspace)
1085 }
1086
1087 fn render_bar(&self, c: &Palette) -> String {
1090 if self.total_tasks == 0 {
1091 return String::new();
1092 }
1093 let mut widths: Vec<usize> = self
1095 .state_counts
1096 .iter()
1097 .map(|(_, n, _)| {
1098 let w = (*n * BAR_WIDTH) / self.total_tasks;
1099 if *n > 0 {
1100 w.max(1)
1101 } else {
1102 0
1103 }
1104 })
1105 .collect();
1106 let mut total: usize = widths.iter().sum();
1108 while total > BAR_WIDTH {
1109 if let Some((idx, _)) =
1110 widths.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w)
1111 {
1112 widths[idx] -= 1;
1113 total -= 1;
1114 } else {
1115 break;
1116 }
1117 }
1118 let mut bar = String::new();
1119 for ((_, _, marker), w) in self.state_counts.iter().zip(widths) {
1120 if w == 0 {
1121 continue;
1122 }
1123 bar.push_str(c.color(marker.color()));
1124 bar.push_str(&"█".repeat(w));
1125 bar.push_str(c.reset);
1126 }
1127 bar
1128 }
1129
1130 fn render_state_labels(&self, c: &Palette) -> String {
1131 self.state_counts
1132 .iter()
1133 .map(|(state, n, marker)| {
1134 format!("{}{} {}{}", c.color(marker.color()), n, state, c.reset)
1135 })
1136 .collect::<Vec<_>>()
1137 .join(" · ")
1138 }
1139
1140 fn render_tree(&self, c: &Palette) -> String {
1141 let mut out = String::new();
1142 let mut collapsed = 0usize;
1143 let mut shown = 0usize;
1144 for row in &self.rows {
1145 if shown >= MAX_TASK_ROWS && row.marker == Marker::Done {
1148 collapsed += 1;
1149 continue;
1150 }
1151 shown += 1;
1152 let gutter = if row.depth > 0 { "│ ".repeat(row.depth) } else { String::new() };
1153 let detail = row.detail.as_deref().unwrap_or("");
1154 let state_cell = c.colored(row.marker.color(), &row.state);
1157 let state_pad = " ".repeat(11usize.saturating_sub(row.state.chars().count()));
1158 out.push_str(&format!(
1159 " {}{}{}{} {:<width$} {}{} {}\n",
1160 c.dim,
1161 gutter,
1162 c.reset,
1163 c.colored(row.marker.color(), &row.marker.glyph().to_string()),
1164 row.id,
1165 state_cell,
1166 state_pad,
1167 detail,
1168 width = 26usize.saturating_sub(row.depth * 2),
1169 ));
1170 }
1171 if collapsed > 0 {
1172 out.push_str(&format!(
1173 " {}… {collapsed} completed tasks collapsed{}\n",
1174 c.dim, c.reset
1175 ));
1176 }
1177 out
1178 }
1179}
1180
1181#[allow(clippy::too_many_arguments)]
1184fn collect_rows(
1185 tasks: &[rhei_core::ast::Task],
1186 depth: usize,
1187 machines: &rhei_validator::MachineSet,
1188 activity: &HashMap<String, TaskActivity>,
1189 halt_causes: &HashMap<String, HaltCause>,
1190 rows: &mut Vec<TaskRow>,
1191 attention: &mut Vec<AttentionRow>,
1192 counts: &mut std::collections::BTreeMap<String, (usize, Marker)>,
1193) {
1194 for task in tasks {
1195 let machine = machines.for_task(&task.id);
1196 let state = normalized_state_name(task.state.as_str(), machine);
1197 let id = task.id.to_string();
1198 let marker = marker_for_task(&id, &state, machine, halt_causes);
1199
1200 let entry = counts.entry(state.clone()).or_insert((0, marker));
1201 entry.0 += 1;
1202
1203 let detail = task_detail(&id, &state, marker, halt_causes, activity);
1204 if marker.needs_attention() && !is_calm_parent(&id, &state, machine, halt_causes) {
1208 let (reason, next) = attention_reason(marker, &id, &state, halt_causes);
1209 attention.push(AttentionRow {
1210 id: id.clone(),
1211 state: state.clone(),
1212 reason,
1213 next,
1214 is_gate: marker == Marker::Gate,
1215 });
1216 }
1217
1218 rows.push(TaskRow { depth, id, state, marker, detail });
1219 collect_rows(
1220 &task.children,
1221 depth + 1,
1222 machines,
1223 activity,
1224 halt_causes,
1225 rows,
1226 attention,
1227 counts,
1228 );
1229 }
1230}
1231
1232fn task_detail(
1235 id: &str,
1236 state: &str,
1237 marker: Marker,
1238 halt_causes: &HashMap<String, HaltCause>,
1239 activity: &HashMap<String, TaskActivity>,
1240) -> Option<String> {
1241 if let Some(act) = activity.get(id) {
1242 let cost = act
1243 .accounting
1244 .as_ref()
1245 .map(|accounting| format!(" · {}", format_summary_cost(accounting)))
1246 .unwrap_or_default();
1247 if let Some(driver) = act.driver {
1248 let label = if act.invocations > 1 {
1249 format!("{driver}×{}", act.invocations)
1250 } else {
1251 driver.to_string()
1252 };
1253 return Some(format!(
1254 "{label} {}{}",
1255 format_duration_short(act.last_duration_ms),
1256 cost
1257 ));
1258 }
1259 if !cost.is_empty() {
1260 return Some(cost.trim_start_matches(" · ").to_string());
1261 }
1262 }
1263 match marker {
1264 Marker::Gate | Marker::Attention => {
1265 Some(attention_reason(marker, id, state, halt_causes).0)
1266 }
1267 _ => None,
1268 }
1269}
1270
1271fn build_task_accounting_rows(
1272 rows: &[TaskRow],
1273 activity: &HashMap<String, TaskActivity>,
1274) -> Vec<TaskAccountingRow> {
1275 rows.iter()
1276 .filter_map(|row| {
1277 let accounting = activity.get(&row.id)?.accounting.as_ref()?;
1278 Some(TaskAccountingRow {
1279 task: row.id.clone(),
1280 cost: format_summary_cost(accounting),
1281 total: format_dimension_value(&accounting.total),
1282 input: format_dimension_value(&accounting.input_total),
1283 input_cached: format_dimension_value(&accounting.input_cached_read),
1284 output: format_dimension_value(&accounting.output_total),
1285 output_cached: format_dimension_value(&accounting.output_cached_read),
1286 coverage: format!("{:?}", accounting.coverage),
1287 })
1288 })
1289 .collect()
1290}
1291
1292fn attention_reason(
1303 marker: Marker,
1304 id: &str,
1305 state: &str,
1306 halt_causes: &HashMap<String, HaltCause>,
1307) -> (String, String) {
1308 if let Some(cause) = halt_causes.get(id) {
1309 return cause.describe(id, state);
1310 }
1311 match marker {
1312 Marker::Gate => HaltCause::Gate.describe(id, state),
1313 _ => HaltCause::Stalled.describe(id, state),
1314 }
1315}
1316
1317fn result_phrase(
1331 attention: &[AttentionRow],
1332 rows: &[TaskRow],
1333 no_work: bool,
1334 advanced_without_work: bool,
1335 cut_short_by_signal: bool,
1340) -> String {
1341 let all_terminal_success =
1342 rows.iter().all(|r| matches!(r.marker, Marker::Done | Marker::TerminalAtStart));
1343 if cut_short_by_signal {
1344 "interrupted — re-run to continue".to_string()
1345 } else if !attention.is_empty() {
1346 "stopped for human attention".to_string()
1349 } else if all_terminal_success && no_work && advanced_without_work {
1350 "completed — no work spawned".to_string()
1353 } else if all_terminal_success {
1354 "completed".to_string()
1355 } else {
1356 "finished".to_string()
1357 }
1358}
1359
1360fn md_cell(value: &str) -> String {
1363 value.replace('|', "\\|").replace('\n', " ")
1364}
1365
1366fn md_link_or_text(value: &str) -> String {
1370 match value.split_once(" / ") {
1371 Some((label, path)) => format!("{} / [{}]({})", md_cell(label), path, path),
1372 None => md_cell(value),
1373 }
1374}
1375
1376fn relativize(path: &std::path::Path, root: &std::path::Path) -> String {
1380 let rel = path.strip_prefix(root).unwrap_or(path);
1381 rel.components()
1382 .map(|c| c.as_os_str().to_string_lossy())
1383 .collect::<Vec<_>>()
1384 .join("/")
1385}
1386
1387fn ledger_outcome_reason(outcome: &LedgerOutcome, exit_code: Option<i32>) -> String {
1389 match outcome {
1390 LedgerOutcome::Completed => match exit_code {
1391 Some(0) | None => "exit 0".to_string(),
1392 Some(code) => format!("exit {code}"),
1393 },
1394 LedgerOutcome::Failed(msg) => {
1395 let msg = msg.lines().next().unwrap_or("").trim();
1396 match exit_code {
1397 Some(code) if msg.is_empty() => format!("failed, exit {code}"),
1398 Some(code) => format!("exit {code}: {msg}"),
1399 None if msg.is_empty() => "failed".to_string(),
1400 None => format!("failed: {msg}"),
1401 }
1402 }
1403 LedgerOutcome::Cancelled => "cancelled".to_string(),
1404 LedgerOutcome::TimedOut => "timed out".to_string(),
1405 LedgerOutcome::Interrupted => "interrupted".to_string(),
1407 }
1408}
1409
1410#[allow(clippy::too_many_arguments)]
1414fn build_ledger(
1415 rows: &[TaskRow],
1416 attention: &[AttentionRow],
1417 halt_causes: &HashMap<String, HaltCause>,
1418 records: &[LedgerRecord],
1419 initial_states: &HashMap<String, String>,
1420 machines: &rhei_validator::MachineSet,
1421 workspace_root: &std::path::Path,
1422) -> Vec<LedgerEntry> {
1423 let attention_by_id: HashMap<&str, &AttentionRow> =
1424 attention.iter().map(|a| (a.id.as_str(), a)).collect();
1425 let mut ledger = Vec::new();
1426 for row in rows {
1427 let task_records: Vec<&LedgerRecord> =
1428 records.iter().filter(|r| r.task == row.id).collect();
1429 if !task_records.is_empty() {
1430 for rec in &task_records {
1431 let log = relativize(&rec.log_path, workspace_root);
1432 ledger.push(LedgerEntry {
1433 task: row.id.clone(),
1434 from: rec.from.clone(),
1435 to: rec.to.clone(),
1436 driver: rec.driver,
1437 invocation: format!("{} / {}", rec.driver, log),
1438 reason: ledger_outcome_reason(&rec.outcome, rec.exit_code),
1439 });
1440 }
1441 let last_to = task_records.last().map(|r| r.to.as_str());
1445 if matches!(row.marker, Marker::Done | Marker::TerminalAtStart)
1446 && last_to != Some(row.state.as_str())
1447 {
1448 ledger.push(LedgerEntry {
1449 task: row.id.clone(),
1450 from: last_to.unwrap_or("").to_string(),
1451 to: row.state.clone(),
1452 driver: "callback-only",
1453 invocation: "none".to_string(),
1454 reason: "advanced without spawning work".to_string(),
1455 });
1456 }
1457 continue;
1458 }
1459
1460 let initial = initial_states.get(&row.id).map(String::as_str);
1463 if row.marker == Marker::TerminalAtStart {
1464 ledger.push(LedgerEntry {
1465 task: row.id.clone(),
1466 from: row.state.clone(),
1467 to: "-".to_string(),
1468 driver: "terminal-at-start",
1469 invocation: "none".to_string(),
1470 reason: "already terminal".to_string(),
1471 });
1472 } else if matches!(row.marker, Marker::Attention | Marker::Gate)
1473 && !is_calm_parent(
1476 &row.id,
1477 &row.state,
1478 machines.for_task(&parse_task_id(&row.id)),
1479 halt_causes,
1480 )
1481 {
1482 let reason = attention_by_id
1483 .get(row.id.as_str())
1484 .map(|a| a.reason.clone())
1485 .unwrap_or_else(|| format!("stalled in non-terminal state {}", row.state));
1486 ledger.push(LedgerEntry {
1487 task: row.id.clone(),
1488 from: row.state.clone(),
1489 to: "-".to_string(),
1490 driver: "blocked",
1491 invocation: "none".to_string(),
1492 reason,
1493 });
1494 } else if initial != Some(row.state.as_str()) {
1495 ledger.push(LedgerEntry {
1498 task: row.id.clone(),
1499 from: initial.unwrap_or("").to_string(),
1500 to: row.state.clone(),
1501 driver: "callback-only",
1502 invocation: "none".to_string(),
1503 reason: "advanced without spawning work".to_string(),
1504 });
1505 } else if is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id))) {
1506 ledger.push(LedgerEntry {
1507 task: row.id.clone(),
1508 from: row.state.clone(),
1509 to: "-".to_string(),
1510 driver: "terminal-at-start",
1511 invocation: "none".to_string(),
1512 reason: "already terminal".to_string(),
1513 });
1514 }
1515 }
1516 ledger
1517}
1518
1519fn build_invocations(
1521 records: &[LedgerRecord],
1522 workspace_root: &std::path::Path,
1523) -> Vec<InvocationRow> {
1524 records
1525 .iter()
1526 .map(|rec| InvocationRow {
1527 driver: rec.driver,
1528 task: rec.task.clone(),
1529 exit: match (&rec.outcome, rec.exit_code) {
1530 (LedgerOutcome::Cancelled, _) => "cancelled".to_string(),
1531 (LedgerOutcome::TimedOut, _) => "timed out".to_string(),
1532 (LedgerOutcome::Interrupted, _) => "interrupted".to_string(),
1533 (_, Some(code)) => format!("exit {code}"),
1534 (_, None) => "—".to_string(),
1535 },
1536 duration_ms: rec.duration_ms,
1537 log: relativize(&rec.log_path, workspace_root),
1538 })
1539 .collect()
1540}
1541
1542fn format_work(agents: u32, programs: u32, callback_only: u32) -> String {
1543 let mut parts = vec![format!("{agents} agents"), format!("{programs} programs")];
1544 if callback_only > 0 {
1545 parts.push(format!("{callback_only} callback-only"));
1546 }
1547 parts.join(" · ")
1548}
1549
1550fn marker_order(marker: Marker) -> u8 {
1551 match marker {
1552 Marker::Done => 0,
1553 Marker::Gate => 1,
1554 Marker::Attention => 2,
1555 Marker::Cancelled => 3,
1556 Marker::TerminalAtStart => 4,
1557 }
1558}
1559
1560fn format_duration_short(ms: u64) -> String {
1561 if ms < 60_000 {
1562 format!("{:.1}s", ms as f64 / 1000.0)
1563 } else {
1564 format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1000)
1565 }
1566}
1567
1568fn format_duration_long(d: std::time::Duration) -> String {
1569 let secs = d.as_secs();
1570 if secs < 60 {
1571 format!("{:.1}s", d.as_secs_f64())
1572 } else {
1573 format!("{}m{:02}s", secs / 60, secs % 60)
1574 }
1575}
1576
1577struct Palette {
1580 color: bool,
1581 reset: &'static str,
1582 bold: &'static str,
1583 dim: &'static str,
1584 red: &'static str,
1585}
1586
1587impl Palette {
1588 fn new(color: bool) -> Self {
1589 Self {
1590 color,
1591 reset: if color { RESET } else { "" },
1592 bold: if color { BOLD } else { "" },
1593 dim: if color { DIM } else { "" },
1594 red: if color { RED } else { "" },
1595 }
1596 }
1597
1598 fn color(&self, code: &'static str) -> &'static str {
1599 if self.color {
1600 code
1601 } else {
1602 ""
1603 }
1604 }
1605
1606 fn colored(&self, code: &'static str, text: &str) -> String {
1607 if self.color {
1608 format!("{code}{text}{RESET}")
1609 } else {
1610 text.to_string()
1611 }
1612 }
1613
1614 fn result_color(&self, result: &str) -> &'static str {
1615 if !self.color {
1616 return "";
1617 }
1618 if result.starts_with("stopped — ") {
1619 RED
1620 } else if result.starts_with("interrupted") {
1621 YELLOW
1624 } else if result.starts_with("stopped") {
1625 YELLOW
1626 } else if result == "completed" {
1627 GREEN
1628 } else {
1629 ""
1630 }
1631 }
1632}
1633
1634#[cfg(test)]
1635mod run_summary_tests {
1636 use super::*;
1637
1638 fn machine() -> rhei_validator::StateMachine {
1639 rhei_validator::StateMachine::builtin_default()
1640 }
1641
1642 fn report(tasks: &[(&str, &str)]) -> RunSummaryReport {
1644 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1645 for (id, state) in tasks {
1646 md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1647 }
1648 let rhei = rhei_core::parse(&md).expect("plan parses");
1649 RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), test_stats(), "plan.rhei.md")
1650 }
1651
1652 fn test_stats() -> RunStats {
1655 RunStats {
1656 agents_spawned: 2,
1657 programs_spawned: 3,
1658 callback_only: 0,
1659 duration: Some(std::time::Duration::from_secs(5)),
1660 dashboard: None,
1661 run_id: "abc123".to_string(),
1662 started_at: Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_749_115_351)),
1663 workspace_root: std::path::PathBuf::from("examples/test"),
1664 command: "rhei run .".to_string(),
1665 parallel: 4,
1666 mode: "agent",
1667 initial_states: HashMap::new(),
1668 dry_run: false,
1669 interrupted: false,
1670 }
1671 }
1672
1673 #[test]
1674 fn markers_classify_by_state_class() {
1675 let m = machine();
1676 assert_eq!(classify_marker("completed", &m), Marker::Done);
1677 assert_eq!(classify_marker("blocked", &m), Marker::Attention);
1678 assert_eq!(classify_marker("cancelled", &m), Marker::Cancelled);
1679 }
1680
1681 #[test]
1686 fn a_parent_waiting_on_its_subtree_reads_as_a_calm_pause() {
1687 let m = machine();
1688 let mut causes: HashMap<String, HaltCause> = HashMap::new();
1689 causes.insert(
1690 "plan.1".to_string(),
1691 HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (human-gate)".to_string() },
1692 );
1693 causes.insert("plan.2".to_string(), HaltCause::Stalled);
1694
1695 assert_eq!(classify_marker("pending", &m), Marker::Attention);
1697 assert_eq!(marker_for_task("plan.1", "pending", &m, &causes), Marker::Gate);
1698 assert_eq!(marker_for_task("plan.2", "pending", &m, &causes), Marker::Attention);
1699 assert_eq!(marker_for_task("plan.3", "pending", &m, &causes), Marker::Attention);
1700
1701 let (reason, _) = attention_reason(Marker::Gate, "plan.1", "pending", &causes);
1704 assert!(
1705 reason.contains("waiting on open descendant Task plan.1.1 (human-gate)"),
1706 "{reason}"
1707 );
1708 }
1709
1710 #[test]
1717 fn one_gate_under_three_ancestors_is_counted_once() {
1718 let rhei = rhei_core::parse(
1719 r#"# Rhei: Deep Subtree
1720---
1721structure:
1722 maxLevels: 4
1723---
1724
1725## Tasks
1726
1727### Task 1: Top
1728**State:** work
1729
1730#### Task 1.1: Middle
1731**State:** work
1732
1733##### Task 1.1.1: Inner
1734**State:** work
1735
1736###### Task 1.1.1.1: Gated leaf
1737**State:** human-gate
1738"#,
1739 )
1740 .expect("plan parses");
1741 let machine = rhei_validator::StateMachine::from_yaml_str(
1742 r#"name: t
1743version: 1
1744states:
1745 work:
1746 initial: true
1747 description: work
1748 human-gate:
1749 description: awaiting a human
1750 gating: true
1751 done:
1752 description: terminal
1753 final: true
1754transitions:
1755 - from: work
1756 to: done
1757 - from: human-gate
1758 to: done
1759"#,
1760 )
1761 .expect("valid state machine");
1762 let report = RunSummaryReport::build(
1763 &rhei,
1764 &rhei_validator::MachineSet::single(machine),
1765 &SummarySink::new(),
1766 test_stats(),
1767 "plan.rhei.md",
1768 );
1769
1770 assert_eq!(
1771 report.attention.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
1772 vec!["1.1.1.1"],
1773 "only the gate itself is halted work"
1774 );
1775
1776 let tty = report.render_tty(false);
1777 assert!(tty.contains("Attention 1 gated · 0 blocked"), "{tty}");
1778
1779 let markdown = report.render_markdown();
1780 assert!(markdown.contains("| could not advance | 1 |"), "{markdown}");
1781 assert_eq!(
1782 report.ledger.iter().filter(|e| e.driver == "blocked").count(),
1783 1,
1784 "one blocked ledger row, not one per ancestor"
1785 );
1786
1787 for id in ["1", "1.1", "1.1.1"] {
1790 let row = report.rows.iter().find(|r| r.id == id).expect("row present");
1791 assert_eq!(row.marker, Marker::Gate, "{id}");
1792 assert!(
1793 row.detail.as_deref().is_some_and(|d| d.contains("waiting on open descendant")),
1794 "{id}: {:?}",
1795 row.detail
1796 );
1797 }
1798 }
1799
1800 #[test]
1804 fn a_failed_parent_keeps_its_attention_marker() {
1805 let m = machine();
1806 let mut causes: HashMap<String, HaltCause> = HashMap::new();
1807 causes.insert(
1808 "plan.1".to_string(),
1809 HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (pending)".to_string() },
1810 );
1811 assert_eq!(marker_for_task("plan.1", "blocked", &m, &causes), Marker::Attention);
1812 }
1813
1814 #[test]
1815 fn plain_render_lists_every_task_with_state() {
1816 let r = report(&[("1", "completed"), ("2", "blocked")]);
1817 let out = r.render_tty(false);
1818 assert!(out.contains("Run Report"), "{out}");
1819 assert!(out.contains("Test Plan"), "{out}");
1820 assert!(out.contains("completed"), "{out}");
1821 assert!(out.contains("blocked"), "{out}");
1822 assert!(!out.contains('\x1b'), "{out}");
1824 }
1825
1826 #[test]
1827 fn attention_block_surfaces_blocked_tasks() {
1828 let r = report(&[("1", "completed"), ("2", "blocked")]);
1829 let out = r.render_tty(false);
1830 assert!(out.contains("Attention"), "{out}");
1831 assert!(out.contains("1 blocked"), "{out}");
1832 assert!(out.contains("stopped for human attention"), "{out}");
1833 }
1834
1835 #[test]
1836 fn all_completed_reads_as_completed() {
1837 let r = report(&[("1", "completed"), ("2", "completed")]);
1838 let out = r.render_tty(false);
1839 assert!(out.contains("completed"), "{out}");
1840 assert!(!out.contains("Attention"), "{out}");
1841 }
1842
1843 #[test]
1844 fn color_render_emits_ansi() {
1845 let r = report(&[("1", "blocked")]);
1846 let out = r.render_tty(true);
1847 assert!(out.contains('\x1b'), "expected ANSI escapes");
1848 }
1849
1850 #[test]
1851 fn duration_formats_short_and_long() {
1852 assert_eq!(format_duration_short(200), "0.2s");
1853 assert_eq!(format_duration_short(8_100), "8.1s");
1854 assert_eq!(format_duration_short(65_000), "1m05s");
1855 assert_eq!(format_duration_long(std::time::Duration::from_secs(724)), "12m04s");
1856 }
1857
1858 fn report_with(tasks: &[(&str, &str)], stats: RunStats) -> RunSummaryReport {
1861 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1862 for (id, state) in tasks {
1863 md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1864 }
1865 let rhei = rhei_core::parse(&md).expect("plan parses");
1866 RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), stats, "plan.rhei.md")
1867 }
1868
1869 #[test]
1870 fn markdown_report_has_all_sections() {
1871 let r = report(&[("1", "completed"), ("2", "blocked")]);
1872 let md = r.render_markdown();
1873 assert!(md.starts_with("# Run Report: Test Plan"), "{md}");
1874 assert!(md.contains("Run: 2025-"), "header carries the ISO start: {md}");
1875 assert!(md.contains("| Final states | Count |"), "{md}");
1876 assert!(md.contains("| Activity | Count |"), "{md}");
1877 assert!(md.contains("## Attention"), "{md}");
1878 assert!(md.contains("## Transition Ledger"), "{md}");
1879 assert!(md.contains("## Task Final States"), "{md}");
1880 }
1881
1882 #[test]
1883 fn run_id_is_stable_for_a_given_start() {
1884 let t = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(1_749_115_351_123_456);
1885 assert_eq!(short_run_id(t), short_run_id(t));
1886 assert_eq!(short_run_id(t).len(), 6);
1887 }
1888
1889 #[test]
1890 fn no_work_run_that_advanced_reads_differently() {
1891 let mut initial = HashMap::new();
1895 initial.insert("1".to_string(), "queued".to_string());
1896 let stats = RunStats {
1897 agents_spawned: 0,
1898 programs_spawned: 0,
1899 callback_only: 1,
1900 initial_states: initial,
1901 ..test_stats()
1902 };
1903 let r = report_with(&[("1", "completed")], stats);
1904 assert_eq!(r.result, "completed — no work spawned");
1905 let md = r.render_markdown();
1906 assert!(md.contains("No agent or program ran"), "{md}");
1907 assert!(md.contains("| 1 | queued | completed | callback-only |"), "{md}");
1909 }
1910
1911 #[test]
1912 fn terminal_at_start_task_is_marked_calm() {
1913 let mut initial = HashMap::new();
1914 initial.insert("done".to_string(), "completed".to_string());
1915 let stats = RunStats { initial_states: initial, ..test_stats() };
1916 let r = report_with(&[("done", "completed")], stats);
1917 assert_eq!(r.terminal_at_start, 1);
1918 let md = r.render_markdown();
1919 assert!(md.contains("terminal at start"), "{md}");
1920 assert!(md.contains("| done | completed | - | terminal-at-start |"), "{md}");
1922 }
1923
1924 #[test]
1925 fn write_to_runtime_emits_latest_and_history() {
1926 let dir = tempfile::tempdir().expect("tmpdir");
1927 let runtime = dir.path().join("runtime");
1928 let stats =
1929 RunStats { workspace_root: dir.path().to_path_buf(), ..test_stats() };
1930 let mut r = report_with(&[("1", "completed")], stats);
1931 r.write_to_runtime(&runtime).expect("write report");
1932 assert!(runtime.join("run-report.md").exists());
1933 assert_eq!(r.report_path.as_deref(), Some("runtime/run-report.md"));
1934 let history = std::fs::read_dir(runtime.join("run-reports"))
1935 .expect("history dir")
1936 .filter_map(Result::ok)
1937 .count();
1938 assert_eq!(history, 1, "one timestamped history entry written");
1939 }
1940
1941 #[test]
1947 fn a_signal_after_the_loop_finished_does_not_relabel_the_result() {
1948 let finished = report_with(&[("1", "completed")], test_stats());
1949 assert_eq!(finished.result, "completed");
1950 let cut_short =
1951 report_with(&[("1", "completed")], RunStats { interrupted: true, ..test_stats() });
1952 assert_eq!(cut_short.result, "interrupted — re-run to continue");
1953 }
1954
1955 #[test]
1956 fn dry_run_result_reads_as_preview() {
1957 let stats = RunStats { dry_run: true, ..test_stats() };
1958 let r = report_with(&[("1", "completed")], stats);
1959 assert_eq!(r.result, "dry run — no changes applied");
1960 assert!(r.render_markdown().contains("Result: dry run — no changes applied"));
1961 }
1962
1963 #[test]
1964 fn dashboard_pointer_gated_on_enabled_this_run() {
1965 let dir = tempfile::tempdir().expect("tmpdir");
1966 let runtime = dir.path().join("runtime");
1967 std::fs::create_dir_all(&runtime).unwrap();
1968 std::fs::write(runtime.join("dashboard.html"), "<html>").unwrap();
1969 assert_eq!(frozen_dashboard_relative_path(false, &runtime, dir.path()), None);
1972 assert_eq!(
1973 frozen_dashboard_relative_path(true, &runtime, dir.path()).as_deref(),
1974 Some("runtime/dashboard.html"),
1975 );
1976 }
1977
1978 #[test]
1979 fn md_cell_escapes_pipes_and_newlines() {
1980 assert_eq!(md_cell("a|b"), "a\\|b");
1981 assert_eq!(md_cell("line1\nline2"), "line1 line2");
1982 }
1983
1984 fn summary_with_spawn(task: &str, from: &str, to: &str, agent: bool) -> SummarySink {
1986 use rhei_tui::EventSink;
1987 let s = SummarySink::new();
1988 let log = std::path::PathBuf::from("runtime/logs/x.log");
1989 s.emit(rhei_tui::RunEvent::SlotAssigned {
1990 slot: 0,
1991 task: task.to_string(),
1992 from: from.to_string(),
1993 to: to.to_string(),
1994 agent: agent.then(|| "mock".to_string()),
1995 template_context: None,
1996 log_path: log.clone(),
1997 started_at: std::time::Instant::now(),
1998 wall_clock: std::time::SystemTime::now(),
1999 });
2000 s.emit(rhei_tui::RunEvent::SlotReleased {
2001 slot: 0,
2002 task: task.to_string(),
2003 from: from.to_string(),
2004 to: to.to_string(),
2005 log_path: log,
2006 outcome: rhei_tui::TaskOutcome::Completed,
2007 finished_at: std::time::Instant::now(),
2008 wall_clock: std::time::SystemTime::now(),
2009 exit_code: Some(0),
2010 duration_ms: 1_200,
2011 });
2012 s
2013 }
2014
2015 #[test]
2016 fn ledger_records_trailing_callback_advance_after_spawn() {
2017 let summary = summary_with_spawn("1", "build", "review", true);
2020 let stats = RunStats { initial_states: HashMap::new(), ..test_stats() };
2021 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
2022 md.push_str("### Task 1: Task 1\n**State:** completed\n\n");
2023 let rhei = rhei_core::parse(&md).expect("plan parses");
2024 let report = RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &summary, stats, "plan.rhei.md");
2025 let md = report.render_markdown();
2026 assert!(md.contains("| 1 | build | review | agent |"), "{md}");
2028 assert!(md.contains("| 1 | review | completed | callback-only |"), "{md}");
2029 }
2030}