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}
56
57pub struct SummarySink {
61 inner: Mutex<SummaryState>,
62}
63
64#[derive(Default)]
65struct SummaryState {
66 inflight: HashMap<u16, &'static str>,
68 tasks: HashMap<String, TaskActivity>,
70 ledger: Vec<LedgerRecord>,
72 usages: Vec<rhei_tui::UsageSummary>,
75 usage_by_task: HashMap<String, Vec<rhei_tui::UsageSummary>>,
77 accounting: Option<rhei_tui::AccountingRunSummary>,
79}
80
81impl SummarySink {
82 pub fn new() -> Self {
83 Self { inner: Mutex::new(SummaryState::default()) }
84 }
85
86 fn snapshot(&self) -> HashMap<String, TaskActivity> {
90 self.inner.lock().map(|state| state.tasks.clone()).unwrap_or_default()
91 }
92
93 fn ledger(&self) -> Vec<LedgerRecord> {
96 self.inner.lock().map(|state| state.ledger.clone()).unwrap_or_default()
97 }
98
99 fn accounting(&self) -> Option<rhei_tui::AccountingRunSummary> {
102 self.inner
103 .lock()
104 .ok()
105 .and_then(|state| {
106 state
107 .accounting
108 .clone()
109 .or_else(|| rhei_tui::summarize_usage_summaries(state.usages.iter()))
110 })
111 }
112}
113
114impl Default for SummarySink {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl rhei_tui::EventSink for SummarySink {
121 fn emit(&self, event: rhei_tui::RunEvent) {
122 let mut state = match self.inner.lock() {
123 Ok(state) => state,
124 Err(_) => return,
125 };
126 match event {
127 rhei_tui::RunEvent::SlotAssigned { slot, task, agent, .. } => {
129 let driver = if agent.is_some() { "agent" } else { "program" };
130 state.inflight.insert(slot, driver);
131 state.tasks.entry(task).or_default().missing_outputs = None;
134 }
135 rhei_tui::RunEvent::SlotReleased {
136 slot,
137 task,
138 from,
139 to,
140 log_path,
141 outcome,
142 exit_code,
143 duration_ms,
144 ..
145 } => {
146 let driver = state.inflight.remove(&slot).unwrap_or("program");
147 let entry = state.tasks.entry(task.clone()).or_default();
148 entry.driver = Some(driver);
149 entry.invocations += 1;
150 entry.last_duration_ms = duration_ms;
151 let outcome = match outcome {
152 rhei_tui::TaskOutcome::Completed => LedgerOutcome::Completed,
153 rhei_tui::TaskOutcome::Failed(msg) => LedgerOutcome::Failed(msg),
154 rhei_tui::TaskOutcome::Cancelled => LedgerOutcome::Cancelled,
155 rhei_tui::TaskOutcome::TimedOut => LedgerOutcome::TimedOut,
156 };
157 state.ledger.push(LedgerRecord {
158 task,
159 from,
160 to,
161 driver,
162 log_path,
163 exit_code,
164 duration_ms,
165 outcome,
166 });
167 }
168 rhei_tui::RunEvent::UsageReported { task, usage, .. } => {
169 state.usages.push(usage.clone());
170 state.usage_by_task.entry(task.clone()).or_default().push(usage);
171 let accounting = state
172 .usage_by_task
173 .get(&task)
174 .and_then(|usages| rhei_tui::summarize_usage_summaries(usages.iter()));
175 if let Some(accounting) = accounting {
176 state.tasks.entry(task).or_default().accounting = Some(accounting);
177 }
178 }
179 rhei_tui::RunEvent::TaskOutputsMissing { task, state: stalled_in, entries } => {
183 state.tasks.entry(task).or_default().missing_outputs =
184 Some((stalled_in, entries));
185 }
186 rhei_tui::RunEvent::RunFinished { summary } => {
187 state.accounting = summary.accounting.clone().or_else(|| {
188 rhei_tui::summarize_usage_summaries(state.usages.iter())
189 });
190 }
191 _ => {}
192 }
193 }
194}
195
196fn emit_run_report(
200 input: &std::path::Path,
201 machines: &rhei_validator::MachineSet,
202 summary: &SummarySink,
203 runtime_dir: &std::path::Path,
204 stats: RunStats,
205) {
206 use std::io::IsTerminal;
207 let Ok(loaded) = load_plan(input) else {
208 return;
209 };
210 let dry_run = stats.dry_run;
213 let plan_arg = plan_arg_for_help(input);
216 let mut report = RunSummaryReport::build(&loaded.rhei, machines, summary, stats, &plan_arg);
217 if !dry_run {
221 if let Err(err) = report.write_to_runtime(runtime_dir) {
222 eprintln!("warning: could not write run report: {err}");
223 }
224 }
225 if std::io::stdout().is_terminal() {
226 let color = std::env::var_os("NO_COLOR").is_none();
228 print!("{}", report.render_tty(color));
229 } else if let Some(report_path) = &report.report_path {
230 println!("Report: {report_path}");
231 }
232}
233
234fn short_run_id(started_at: std::time::SystemTime) -> String {
238 let nanos =
239 started_at.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
240 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
241 for b in nanos.to_le_bytes() {
242 hash ^= b as u64;
243 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
244 }
245 format!("{:06x}", hash & 0xff_ffff)
246}
247
248fn frozen_dashboard_relative_path(
252 enabled_this_run: bool,
253 runtime_dir: &std::path::Path,
254 workspace_root: &std::path::Path,
255) -> Option<String> {
256 if !enabled_this_run {
257 return None;
258 }
259 let path = runtime_dir.join("dashboard.html");
260 path.exists().then(|| relativize(&path, workspace_root))
261}
262
263fn current_command_line() -> String {
266 let mut args: Vec<String> = std::env::args().collect();
267 if let Some(first) = args.first_mut() {
268 *first = "rhei".to_string();
269 }
270 args.join(" ")
271}
272
273fn collect_initial_states(
277 rhei: &rhei_core::ast::Rhei,
278 machines: &rhei_validator::MachineSet,
279) -> HashMap<String, String> {
280 fn walk(
281 tasks: &[rhei_core::ast::Task],
282 machines: &rhei_validator::MachineSet,
283 out: &mut HashMap<String, String>,
284 ) {
285 for task in tasks {
286 out.insert(
287 task.id.to_string(),
288 normalized_state_name(task.state.as_str(), machines.for_task(&task.id)),
289 );
290 walk(&task.children, machines, out);
291 }
292 }
293 let mut out = HashMap::new();
294 walk(&rhei.tasks, machines, &mut out);
295 out
296}
297
298struct RunReportGuard<'a> {
302 input: &'a std::path::Path,
303 machines: &'a rhei_validator::MachineSet,
304 runtime_dir: std::path::PathBuf,
305 run_started: std::time::Instant,
306 run_started_wall: std::time::SystemTime,
307 run_id: String,
308 workspace_root: std::path::PathBuf,
309 command: String,
310 parallel: usize,
311 mode: &'static str,
312 initial_states: HashMap<String, String>,
313 dry_run: bool,
316 summary: Option<std::sync::Arc<SummarySink>>,
318 armed: bool,
320}
321
322impl RunReportGuard<'_> {
323 fn disarm(&mut self) {
325 self.armed = false;
326 }
327}
328
329impl Drop for RunReportGuard<'_> {
330 fn drop(&mut self) {
331 if !self.armed || self.dry_run {
333 return;
334 }
335 let Some(summary) = self.summary.clone() else {
336 return;
337 };
338 let ledger = summary.ledger();
341 let agents = ledger.iter().filter(|r| r.driver == "agent").count() as u32;
342 let programs = ledger.iter().filter(|r| r.driver == "program").count() as u32;
343 emit_run_report(
344 self.input,
345 self.machines,
346 &summary,
347 &self.runtime_dir,
348 RunStats {
349 agents_spawned: agents,
350 programs_spawned: programs,
351 callback_only: 0,
352 duration: Some(self.run_started.elapsed()),
353 dashboard: None,
354 run_id: self.run_id.clone(),
355 started_at: Some(self.run_started_wall),
356 workspace_root: self.workspace_root.clone(),
357 command: self.command.clone(),
358 parallel: self.parallel,
359 mode: self.mode,
360 initial_states: self.initial_states.clone(),
361 dry_run: false,
362 },
363 );
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum Marker {
371 Done,
373 Gate,
375 Attention,
377 Cancelled,
379 TerminalAtStart,
381}
382
383impl Marker {
384 fn glyph(self) -> char {
385 match self {
386 Marker::Done => '✓',
387 Marker::Gate => '⏸',
388 Marker::Attention => '!',
389 Marker::Cancelled => '⊘',
390 Marker::TerminalAtStart => '·',
391 }
392 }
393
394 fn color(self) -> &'static str {
398 match self {
399 Marker::Done => GREEN,
400 Marker::Gate => YELLOW,
401 Marker::Attention => RED,
402 Marker::Cancelled => DIM,
403 Marker::TerminalAtStart => DIM,
404 }
405 }
406
407 fn needs_attention(self) -> bool {
409 matches!(self, Marker::Gate | Marker::Attention)
410 }
411}
412
413fn state_is_failure(state: &str) -> bool {
415 matches!(state, "blocked" | "failed")
416}
417
418fn classify_marker(state: &str, machine: &rhei_validator::StateMachine) -> Marker {
422 match state {
423 "cancelled" | "canceled" => return Marker::Cancelled,
424 _ if state_is_failure(state) => return Marker::Attention,
425 _ => {}
426 }
427 let def = machine.states.get(state);
428 if def.map(|d| d.gating).unwrap_or(false) {
429 Marker::Gate
430 } else if def.map(|d| d.terminal).unwrap_or(false) {
431 Marker::Done
432 } else {
433 Marker::Attention
434 }
435}
436
437fn marker_for_task(
448 id: &str,
449 state: &str,
450 machine: &rhei_validator::StateMachine,
451 halt_causes: &HashMap<String, HaltCause>,
452) -> Marker {
453 if is_calm_parent(id, state, machine, halt_causes) {
454 return Marker::Gate;
455 }
456 classify_marker(state, machine)
457}
458
459fn is_calm_parent(
476 id: &str,
477 state: &str,
478 machine: &rhei_validator::StateMachine,
479 halt_causes: &HashMap<String, HaltCause>,
480) -> bool {
481 classify_marker(state, machine) == Marker::Attention
482 && !state_is_failure(state)
483 && matches!(halt_causes.get(id), Some(HaltCause::WaitingOnDescendants { .. }))
484}
485
486struct TaskRow {
488 depth: usize,
489 id: String,
490 state: String,
491 marker: Marker,
492 detail: Option<String>,
494}
495
496struct AttentionRow {
499 id: String,
500 state: String,
501 reason: String,
502 next: String,
503 is_gate: bool,
507}
508
509pub struct RunStats {
511 pub agents_spawned: u32,
512 pub programs_spawned: u32,
513 pub callback_only: u32,
514 pub duration: Option<std::time::Duration>,
515 pub dashboard: Option<String>,
516 pub run_id: String,
518 pub started_at: Option<std::time::SystemTime>,
521 pub workspace_root: std::path::PathBuf,
523 pub command: String,
525 pub parallel: usize,
527 pub mode: &'static str,
529 pub initial_states: HashMap<String, String>,
533 pub dry_run: bool,
536}
537
538struct LedgerEntry {
540 task: String,
541 from: String,
542 to: String,
544 driver: &'static str,
546 invocation: String,
548 reason: String,
549}
550
551struct InvocationRow {
553 driver: &'static str,
554 task: String,
555 exit: String,
557 duration_ms: u64,
558 log: String,
560}
561
562struct TaskAccountingRow {
564 task: String,
565 cost: String,
566 total: String,
567 input: String,
568 input_cached: String,
569 output: String,
570 output_cached: String,
571 coverage: String,
572}
573
574pub struct RunSummaryReport {
576 title: String,
577 result: String,
578 duration: Option<std::time::Duration>,
579 state_counts: Vec<(String, usize, Marker)>,
581 total_tasks: usize,
582 work: String,
583 accounting: Option<rhei_tui::AccountingRunSummary>,
584 attention: Vec<AttentionRow>,
585 rows: Vec<TaskRow>,
586 dashboard: Option<String>,
587 run_id: String,
589 started_at: Option<std::time::SystemTime>,
590 workspace: String,
591 command: String,
592 parallel: usize,
593 mode: &'static str,
594 agents_spawned: u32,
595 programs_spawned: u32,
596 callback_only: u32,
597 terminal_at_start: usize,
598 ledger: Vec<LedgerEntry>,
599 invocations: Vec<InvocationRow>,
600 task_accounting: Vec<TaskAccountingRow>,
601 report_path: Option<String>,
603 history_path: Option<String>,
604}
605
606const RESET: &str = "\x1b[0m";
608const BOLD: &str = "\x1b[1m";
609const DIM: &str = "\x1b[2m";
610const RED: &str = "\x1b[31m";
611const GREEN: &str = "\x1b[32m";
612const YELLOW: &str = "\x1b[33m";
613
614const BAR_WIDTH: usize = 24;
616const MAX_TASK_ROWS: usize = 40;
618const MAX_ATTENTION_ROWS: usize = 5;
620
621impl RunSummaryReport {
622 pub fn build(
625 rhei: &rhei_core::ast::Rhei,
626 machines: &rhei_validator::MachineSet,
627 summary: &SummarySink,
628 stats: RunStats,
629 plan_arg: &str,
630 ) -> Self {
631 let activity = summary.snapshot();
632
633 let halt_causes: HashMap<String, HaltCause> = classify_halted_tasks(
637 rhei,
638 machines,
639 &None,
640 &|id| activity.contains_key(id),
641 &|id, state| {
645 activity
646 .get(id)
647 .and_then(|entry| entry.missing_outputs.as_ref())
648 .filter(|(stalled_in, entries)| stalled_in == state && !entries.is_empty())
649 .map(|(_, entries)| entries.clone())
650 },
651 plan_arg,
652 )
653 .into_iter()
654 .map(|(task, cause)| (task.id.to_string(), cause))
655 .collect();
656
657 let mut rows = Vec::new();
659 let mut attention = Vec::new();
660 let mut counts: std::collections::BTreeMap<String, (usize, Marker)> =
661 std::collections::BTreeMap::new();
662 collect_rows(
663 &rhei.tasks,
664 0,
665 machines,
666 &activity,
667 &halt_causes,
668 &mut rows,
669 &mut attention,
670 &mut counts,
671 );
672
673 let mut terminal_at_start = 0usize;
677 for row in &mut rows {
678 let was = stats.initial_states.get(&row.id).map(String::as_str);
679 let unchanged_terminal = was == Some(row.state.as_str())
680 && is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id)));
681 if unchanged_terminal {
682 terminal_at_start += 1;
683 if row.marker == Marker::Done {
686 row.marker = Marker::TerminalAtStart;
687 row.detail = Some("terminal at start".to_string());
688 }
689 }
690 }
691
692 let total_tasks = rows.len();
693
694 let mut state_counts: Vec<(String, usize, Marker)> =
696 counts.into_iter().map(|(state, (n, marker))| (state, n, marker)).collect();
697 state_counts.sort_by_key(|(_, _, marker)| marker_order(*marker));
698
699 let no_work = stats.agents_spawned == 0 && stats.programs_spawned == 0;
700 let advanced_without_work = rows.iter().any(|r| {
701 r.marker == Marker::Done
702 && stats.initial_states.get(&r.id).map(String::as_str) != Some(r.state.as_str())
703 });
704 let result = if stats.dry_run {
707 "dry run — no changes applied".to_string()
708 } else {
709 result_phrase(&attention, &rows, no_work, advanced_without_work)
710 };
711 let work = format_work(stats.agents_spawned, stats.programs_spawned, stats.callback_only);
712 let accounting = summary.accounting();
713 let task_accounting = build_task_accounting_rows(&rows, &activity);
714
715 let ledger = build_ledger(
716 &rows,
717 &attention,
718 &halt_causes,
719 &summary.ledger(),
720 &stats.initial_states,
721 machines,
722 &stats.workspace_root,
723 );
724 let invocations = build_invocations(&summary.ledger(), &stats.workspace_root);
725
726 Self {
727 title: rhei.title.clone(),
728 result,
729 duration: stats.duration,
730 state_counts,
731 total_tasks,
732 work,
733 accounting,
734 attention,
735 rows,
736 dashboard: stats.dashboard,
737 run_id: stats.run_id,
738 started_at: stats.started_at,
739 workspace: stats.workspace_root.display().to_string(),
740 command: stats.command,
741 parallel: stats.parallel,
742 mode: stats.mode,
743 agents_spawned: stats.agents_spawned,
744 programs_spawned: stats.programs_spawned,
745 callback_only: stats.callback_only,
746 terminal_at_start,
747 ledger,
748 invocations,
749 task_accounting,
750 report_path: None,
751 history_path: None,
752 }
753 }
754
755 pub fn render_tty(&self, color: bool) -> String {
758 let c = Palette::new(color);
759 let mut out = String::new();
760
761 let dur = self.duration.map(format_duration_long).unwrap_or_default();
763 out.push_str(&format!(
764 "\n{}Run Report{} {}{}{}",
765 c.bold, c.reset, c.bold, self.title, c.reset
766 ));
767 if !dur.is_empty() {
768 out.push_str(&format!(" {}{}{}", c.dim, dur, c.reset));
769 }
770 out.push('\n');
771 out.push_str(&format!(" {}{}{}\n\n", c.result_color(&self.result), self.result, c.reset));
772
773 out.push_str(" States ");
775 out.push_str(&self.render_bar(&c));
776 out.push_str(" ");
777 out.push_str(&self.render_state_labels(&c));
778 out.push('\n');
779 out.push_str(&format!(" Work {}\n", self.work));
780 if let Some(accounting) = &self.accounting {
781 out.push_str(&format!(
784 " Cost {} · Total {} · In {} · In cached {} · Out {} · Out cached {} · Coverage {:?}\n",
785 format_summary_cost(accounting),
786 format_dimension_value(&accounting.total),
787 format_dimension_value(&accounting.input_total),
788 format_dimension_value(&accounting.input_cached_read),
789 format_dimension_value(&accounting.output_total),
790 format_dimension_value(&accounting.output_cached_read),
791 accounting.coverage,
792 ));
793 }
794
795 if !self.attention.is_empty() {
797 let gated = self.attention.iter().filter(|a| a.is_gate).count();
798 let blocked = self.attention.len() - gated;
799 out.push_str(&format!(
800 "\n{}Attention{} {} gated · {} blocked\n",
801 c.bold, c.reset, gated, blocked
802 ));
803 for row in self.attention.iter().take(MAX_ATTENTION_ROWS) {
804 out.push_str(&format!(
805 " {}!{} {:<26} {}{:<11}{} {}\n",
806 c.red, c.reset, row.id, c.dim, row.state, c.reset, row.reason
807 ));
808 out.push_str(&format!(" {}→ {}{}\n", c.dim, row.next, c.reset));
809 }
810 if self.attention.len() > MAX_ATTENTION_ROWS {
811 out.push_str(&format!(
812 " {}… {} more in the report{}\n",
813 c.dim,
814 self.attention.len() - MAX_ATTENTION_ROWS,
815 c.reset
816 ));
817 }
818 }
819
820 out.push_str(&format!(
822 "\n{}Tasks{} {} tasks · source order\n",
823 c.bold, c.reset, self.total_tasks
824 ));
825 out.push_str(&self.render_tree(&c));
826
827 out.push('\n');
830 if let Some(report) = &self.report_path {
831 out.push_str(&format!("Report {report}\n"));
832 }
833 if let Some(history) = &self.history_path {
834 out.push_str(&format!("History {history}\n"));
835 }
836 if let Some(dashboard) = &self.dashboard {
837 out.push_str(&format!("Dashboard {dashboard}\n"));
838 }
839 let trailing_newline = out.ends_with('\n');
841 let mut trimmed = out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
842 if trailing_newline {
843 trimmed.push('\n');
844 }
845 trimmed
846 }
847
848 pub fn render_markdown(&self) -> String {
852 let mut out = String::new();
853
854 out.push_str(&format!("# Run Report: {}\n\n", self.title));
856 let when = self
857 .started_at
858 .map(format_iso8601_utc)
859 .map(|ts| format!("{ts} / {}", self.run_id))
860 .unwrap_or_else(|| self.run_id.clone());
861 out.push_str(&format!("Run: {when}\n"));
862 out.push_str(&format!("Workspace: {}\n", self.workspace));
863 out.push_str(&format!("Command: {}\n", self.command));
864 out.push_str(&format!("Mode: {} · parallel {}\n", self.mode, self.parallel));
865 if let Some(dur) = self.duration {
866 out.push_str(&format!("Duration: {}\n", format_duration_long(dur)));
867 }
868 out.push_str(&format!("Result: {}\n", self.result));
869 if let Some(dashboard) = &self.dashboard {
870 out.push_str(&format!("Dashboard: {dashboard}\n"));
871 }
872 out.push('\n');
873
874 out.push_str("| Final states | Count |\n| --- | ---: |\n");
877 for (state, n, _) in &self.state_counts {
878 out.push_str(&format!("| {state} | {n} |\n"));
879 }
880 out.push('\n');
881 let could_not_advance = self.attention.len();
882 out.push_str("| Activity | Count |\n| --- | ---: |\n");
883 out.push_str(&format!("| agent invocations | {} |\n", self.agents_spawned));
884 out.push_str(&format!("| program invocations | {} |\n", self.programs_spawned));
885 out.push_str(&format!("| callback-only transitions | {} |\n", self.callback_only));
886 out.push_str(&format!("| terminal at start | {} |\n", self.terminal_at_start));
887 out.push_str(&format!("| could not advance | {could_not_advance} |\n"));
888 out.push('\n');
889 if let Some(accounting) = &self.accounting {
890 out.push_str("| Accounting | Value |\n| --- | ---: |\n");
892 out.push_str(&format!("| cost | {} |\n", format_summary_cost(accounting)));
893 out.push_str(&format!(
894 "| total tokens | {} |\n",
895 format_dimension_value(&accounting.total)
896 ));
897 out.push_str(&format!(
898 "| input tokens | {} |\n",
899 format_dimension_value(&accounting.input_total)
900 ));
901 out.push_str(&format!(
902 "| input cached | {} |\n",
903 format_dimension_value(&accounting.input_cached_read)
904 ));
905 out.push_str(&format!(
906 "| output tokens | {} |\n",
907 format_dimension_value(&accounting.output_total)
908 ));
909 out.push_str(&format!(
910 "| output cached | {} |\n",
911 format_dimension_value(&accounting.output_cached_read)
912 ));
913 out.push_str(&format!("| coverage | {:?} |\n", accounting.coverage));
914 out.push('\n');
915 }
916 if self.agents_spawned == 0 && self.programs_spawned == 0 {
917 out.push_str(
918 "> No agent or program ran this run. Any task that advanced did so through \
919 callbacks, transition rules, or outputs that already existed — inspect the \
920 ledger below before assuming work was performed.\n\n",
921 );
922 }
923
924 if !self.attention.is_empty() {
926 out.push_str("## Attention\n\n");
927 out.push_str("| Task | State | Reason | Next action |\n| --- | --- | --- | --- |\n");
928 for a in &self.attention {
929 out.push_str(&format!(
930 "| {} | {} | {} | {} |\n",
931 md_cell(&a.id),
932 md_cell(&a.state),
933 md_cell(&a.reason),
934 md_cell(&a.next),
935 ));
936 }
937 out.push('\n');
938 }
939
940 out.push_str("## Transition Ledger\n\n");
942 out.push_str(
943 "| Task | From | To | Driver | Invocation | Reason |\n\
944 | --- | --- | --- | --- | --- | --- |\n",
945 );
946 for e in &self.ledger {
947 out.push_str(&format!(
948 "| {} | {} | {} | {} | {} | {} |\n",
949 e.task,
950 md_cell(&e.from),
951 md_cell(&e.to),
952 e.driver,
953 md_link_or_text(&e.invocation),
954 md_cell(&e.reason),
955 ));
956 }
957 out.push('\n');
958
959 out.push_str("## Task Final States\n\n");
961 for row in &self.rows {
962 let indent = " ".repeat(row.depth);
963 let detail = row.detail.as_deref().unwrap_or("");
964 let detail = if detail.is_empty() {
965 String::new()
966 } else {
967 format!(" — {detail}")
968 };
969 out.push_str(&format!(
970 "{indent}- {} `{}` ({}){detail}\n",
971 row.marker.glyph(),
972 row.id,
973 row.state,
974 ));
975 }
976 out.push('\n');
977
978 if !self.task_accounting.is_empty() {
979 out.push_str("## Task Costs\n\n");
980 out.push_str(
981 "| Task | Cost | Total | Input | Input cached | Output | Output cached | Coverage |\n\
982 | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n",
983 );
984 for row in &self.task_accounting {
985 out.push_str(&format!(
986 "| {} | {} | {} | {} | {} | {} | {} | {} |\n",
987 md_cell(&row.task),
988 row.cost,
989 row.total,
990 row.input,
991 row.input_cached,
992 row.output,
993 row.output_cached,
994 row.coverage,
995 ));
996 }
997 out.push('\n');
998 }
999
1000 if !self.invocations.is_empty() {
1002 out.push_str("## Invocations\n\n");
1003 out.push_str(
1004 "| Task | Driver | Exit | Duration | Log |\n| --- | --- | --- | --- | --- |\n",
1005 );
1006 for inv in &self.invocations {
1007 out.push_str(&format!(
1008 "| {} | {} | {} | {} | [{}]({}) |\n",
1009 inv.task,
1010 inv.driver,
1011 inv.exit,
1012 format_duration_short(inv.duration_ms),
1013 inv.log,
1014 inv.log,
1015 ));
1016 }
1017 out.push('\n');
1018 }
1019
1020 out
1021 }
1022
1023 pub fn write_to_runtime(&mut self, runtime_dir: &std::path::Path) -> std::io::Result<()> {
1027 let body = self.render_markdown();
1028 let latest = runtime_dir.join("run-report.md");
1029 let history_dir = runtime_dir.join("run-reports");
1030 std::fs::create_dir_all(&history_dir)?;
1031 let stamp = self
1032 .started_at
1033 .map(format_iso8601_utc)
1034 .map(|ts| ts.replace(':', "-"))
1035 .unwrap_or_else(|| "unknown".to_string());
1036 let history = history_dir.join(format!("{stamp}-{}.md", self.run_id));
1037 std::fs::write(&latest, &body)?;
1038 std::fs::write(&history, &body)?;
1039 self.report_path = Some(relativize(&latest, &self.workspace_root_path()));
1040 self.history_path = Some(relativize(&history, &self.workspace_root_path()));
1041 Ok(())
1042 }
1043
1044 fn workspace_root_path(&self) -> std::path::PathBuf {
1046 std::path::PathBuf::from(&self.workspace)
1047 }
1048
1049 fn render_bar(&self, c: &Palette) -> String {
1052 if self.total_tasks == 0 {
1053 return String::new();
1054 }
1055 let mut widths: Vec<usize> = self
1057 .state_counts
1058 .iter()
1059 .map(|(_, n, _)| {
1060 let w = (*n * BAR_WIDTH) / self.total_tasks;
1061 if *n > 0 {
1062 w.max(1)
1063 } else {
1064 0
1065 }
1066 })
1067 .collect();
1068 let mut total: usize = widths.iter().sum();
1070 while total > BAR_WIDTH {
1071 if let Some((idx, _)) =
1072 widths.iter().enumerate().filter(|(_, w)| **w > 1).max_by_key(|(_, w)| **w)
1073 {
1074 widths[idx] -= 1;
1075 total -= 1;
1076 } else {
1077 break;
1078 }
1079 }
1080 let mut bar = String::new();
1081 for ((_, _, marker), w) in self.state_counts.iter().zip(widths) {
1082 if w == 0 {
1083 continue;
1084 }
1085 bar.push_str(c.color(marker.color()));
1086 bar.push_str(&"█".repeat(w));
1087 bar.push_str(c.reset);
1088 }
1089 bar
1090 }
1091
1092 fn render_state_labels(&self, c: &Palette) -> String {
1093 self.state_counts
1094 .iter()
1095 .map(|(state, n, marker)| {
1096 format!("{}{} {}{}", c.color(marker.color()), n, state, c.reset)
1097 })
1098 .collect::<Vec<_>>()
1099 .join(" · ")
1100 }
1101
1102 fn render_tree(&self, c: &Palette) -> String {
1103 let mut out = String::new();
1104 let mut collapsed = 0usize;
1105 let mut shown = 0usize;
1106 for row in &self.rows {
1107 if shown >= MAX_TASK_ROWS && row.marker == Marker::Done {
1110 collapsed += 1;
1111 continue;
1112 }
1113 shown += 1;
1114 let gutter = if row.depth > 0 { "│ ".repeat(row.depth) } else { String::new() };
1115 let detail = row.detail.as_deref().unwrap_or("");
1116 let state_cell = c.colored(row.marker.color(), &row.state);
1119 let state_pad = " ".repeat(11usize.saturating_sub(row.state.chars().count()));
1120 out.push_str(&format!(
1121 " {}{}{}{} {:<width$} {}{} {}\n",
1122 c.dim,
1123 gutter,
1124 c.reset,
1125 c.colored(row.marker.color(), &row.marker.glyph().to_string()),
1126 row.id,
1127 state_cell,
1128 state_pad,
1129 detail,
1130 width = 26usize.saturating_sub(row.depth * 2),
1131 ));
1132 }
1133 if collapsed > 0 {
1134 out.push_str(&format!(
1135 " {}… {collapsed} completed tasks collapsed{}\n",
1136 c.dim, c.reset
1137 ));
1138 }
1139 out
1140 }
1141}
1142
1143#[allow(clippy::too_many_arguments)]
1146fn collect_rows(
1147 tasks: &[rhei_core::ast::Task],
1148 depth: usize,
1149 machines: &rhei_validator::MachineSet,
1150 activity: &HashMap<String, TaskActivity>,
1151 halt_causes: &HashMap<String, HaltCause>,
1152 rows: &mut Vec<TaskRow>,
1153 attention: &mut Vec<AttentionRow>,
1154 counts: &mut std::collections::BTreeMap<String, (usize, Marker)>,
1155) {
1156 for task in tasks {
1157 let machine = machines.for_task(&task.id);
1158 let state = normalized_state_name(task.state.as_str(), machine);
1159 let id = task.id.to_string();
1160 let marker = marker_for_task(&id, &state, machine, halt_causes);
1161
1162 let entry = counts.entry(state.clone()).or_insert((0, marker));
1163 entry.0 += 1;
1164
1165 let detail = task_detail(&id, &state, marker, halt_causes, activity);
1166 if marker.needs_attention() && !is_calm_parent(&id, &state, machine, halt_causes) {
1170 let (reason, next) = attention_reason(marker, &id, &state, halt_causes);
1171 attention.push(AttentionRow {
1172 id: id.clone(),
1173 state: state.clone(),
1174 reason,
1175 next,
1176 is_gate: marker == Marker::Gate,
1177 });
1178 }
1179
1180 rows.push(TaskRow { depth, id, state, marker, detail });
1181 collect_rows(
1182 &task.children,
1183 depth + 1,
1184 machines,
1185 activity,
1186 halt_causes,
1187 rows,
1188 attention,
1189 counts,
1190 );
1191 }
1192}
1193
1194fn task_detail(
1197 id: &str,
1198 state: &str,
1199 marker: Marker,
1200 halt_causes: &HashMap<String, HaltCause>,
1201 activity: &HashMap<String, TaskActivity>,
1202) -> Option<String> {
1203 if let Some(act) = activity.get(id) {
1204 let cost = act
1205 .accounting
1206 .as_ref()
1207 .map(|accounting| format!(" · {}", format_summary_cost(accounting)))
1208 .unwrap_or_default();
1209 if let Some(driver) = act.driver {
1210 let label = if act.invocations > 1 {
1211 format!("{driver}×{}", act.invocations)
1212 } else {
1213 driver.to_string()
1214 };
1215 return Some(format!(
1216 "{label} {}{}",
1217 format_duration_short(act.last_duration_ms),
1218 cost
1219 ));
1220 }
1221 if !cost.is_empty() {
1222 return Some(cost.trim_start_matches(" · ").to_string());
1223 }
1224 }
1225 match marker {
1226 Marker::Gate | Marker::Attention => {
1227 Some(attention_reason(marker, id, state, halt_causes).0)
1228 }
1229 _ => None,
1230 }
1231}
1232
1233fn build_task_accounting_rows(
1234 rows: &[TaskRow],
1235 activity: &HashMap<String, TaskActivity>,
1236) -> Vec<TaskAccountingRow> {
1237 rows.iter()
1238 .filter_map(|row| {
1239 let accounting = activity.get(&row.id)?.accounting.as_ref()?;
1240 Some(TaskAccountingRow {
1241 task: row.id.clone(),
1242 cost: format_summary_cost(accounting),
1243 total: format_dimension_value(&accounting.total),
1244 input: format_dimension_value(&accounting.input_total),
1245 input_cached: format_dimension_value(&accounting.input_cached_read),
1246 output: format_dimension_value(&accounting.output_total),
1247 output_cached: format_dimension_value(&accounting.output_cached_read),
1248 coverage: format!("{:?}", accounting.coverage),
1249 })
1250 })
1251 .collect()
1252}
1253
1254fn attention_reason(
1265 marker: Marker,
1266 id: &str,
1267 state: &str,
1268 halt_causes: &HashMap<String, HaltCause>,
1269) -> (String, String) {
1270 if let Some(cause) = halt_causes.get(id) {
1271 return cause.describe(id, state);
1272 }
1273 match marker {
1274 Marker::Gate => HaltCause::Gate.describe(id, state),
1275 _ => HaltCause::Stalled.describe(id, state),
1276 }
1277}
1278
1279fn result_phrase(
1280 attention: &[AttentionRow],
1281 rows: &[TaskRow],
1282 no_work: bool,
1283 advanced_without_work: bool,
1284) -> String {
1285 let all_terminal_success =
1286 rows.iter().all(|r| matches!(r.marker, Marker::Done | Marker::TerminalAtStart));
1287 if !attention.is_empty() {
1288 "stopped for human attention".to_string()
1291 } else if all_terminal_success && no_work && advanced_without_work {
1292 "completed — no work spawned".to_string()
1295 } else if all_terminal_success {
1296 "completed".to_string()
1297 } else {
1298 "finished".to_string()
1299 }
1300}
1301
1302fn md_cell(value: &str) -> String {
1305 value.replace('|', "\\|").replace('\n', " ")
1306}
1307
1308fn md_link_or_text(value: &str) -> String {
1312 match value.split_once(" / ") {
1313 Some((label, path)) => format!("{} / [{}]({})", md_cell(label), path, path),
1314 None => md_cell(value),
1315 }
1316}
1317
1318fn relativize(path: &std::path::Path, root: &std::path::Path) -> String {
1322 let rel = path.strip_prefix(root).unwrap_or(path);
1323 rel.components()
1324 .map(|c| c.as_os_str().to_string_lossy())
1325 .collect::<Vec<_>>()
1326 .join("/")
1327}
1328
1329fn ledger_outcome_reason(outcome: &LedgerOutcome, exit_code: Option<i32>) -> String {
1331 match outcome {
1332 LedgerOutcome::Completed => match exit_code {
1333 Some(0) | None => "exit 0".to_string(),
1334 Some(code) => format!("exit {code}"),
1335 },
1336 LedgerOutcome::Failed(msg) => {
1337 let msg = msg.lines().next().unwrap_or("").trim();
1338 match exit_code {
1339 Some(code) if msg.is_empty() => format!("failed, exit {code}"),
1340 Some(code) => format!("exit {code}: {msg}"),
1341 None if msg.is_empty() => "failed".to_string(),
1342 None => format!("failed: {msg}"),
1343 }
1344 }
1345 LedgerOutcome::Cancelled => "cancelled".to_string(),
1346 LedgerOutcome::TimedOut => "timed out".to_string(),
1347 }
1348}
1349
1350#[allow(clippy::too_many_arguments)]
1354fn build_ledger(
1355 rows: &[TaskRow],
1356 attention: &[AttentionRow],
1357 halt_causes: &HashMap<String, HaltCause>,
1358 records: &[LedgerRecord],
1359 initial_states: &HashMap<String, String>,
1360 machines: &rhei_validator::MachineSet,
1361 workspace_root: &std::path::Path,
1362) -> Vec<LedgerEntry> {
1363 let attention_by_id: HashMap<&str, &AttentionRow> =
1364 attention.iter().map(|a| (a.id.as_str(), a)).collect();
1365 let mut ledger = Vec::new();
1366 for row in rows {
1367 let task_records: Vec<&LedgerRecord> =
1368 records.iter().filter(|r| r.task == row.id).collect();
1369 if !task_records.is_empty() {
1370 for rec in &task_records {
1371 let log = relativize(&rec.log_path, workspace_root);
1372 ledger.push(LedgerEntry {
1373 task: row.id.clone(),
1374 from: rec.from.clone(),
1375 to: rec.to.clone(),
1376 driver: rec.driver,
1377 invocation: format!("{} / {}", rec.driver, log),
1378 reason: ledger_outcome_reason(&rec.outcome, rec.exit_code),
1379 });
1380 }
1381 let last_to = task_records.last().map(|r| r.to.as_str());
1385 if matches!(row.marker, Marker::Done | Marker::TerminalAtStart)
1386 && last_to != Some(row.state.as_str())
1387 {
1388 ledger.push(LedgerEntry {
1389 task: row.id.clone(),
1390 from: last_to.unwrap_or("").to_string(),
1391 to: row.state.clone(),
1392 driver: "callback-only",
1393 invocation: "none".to_string(),
1394 reason: "advanced without spawning work".to_string(),
1395 });
1396 }
1397 continue;
1398 }
1399
1400 let initial = initial_states.get(&row.id).map(String::as_str);
1403 if row.marker == Marker::TerminalAtStart {
1404 ledger.push(LedgerEntry {
1405 task: row.id.clone(),
1406 from: row.state.clone(),
1407 to: "-".to_string(),
1408 driver: "terminal-at-start",
1409 invocation: "none".to_string(),
1410 reason: "already terminal".to_string(),
1411 });
1412 } else if matches!(row.marker, Marker::Attention | Marker::Gate)
1413 && !is_calm_parent(
1416 &row.id,
1417 &row.state,
1418 machines.for_task(&parse_task_id(&row.id)),
1419 halt_causes,
1420 )
1421 {
1422 let reason = attention_by_id
1423 .get(row.id.as_str())
1424 .map(|a| a.reason.clone())
1425 .unwrap_or_else(|| format!("stalled in non-terminal state {}", row.state));
1426 ledger.push(LedgerEntry {
1427 task: row.id.clone(),
1428 from: row.state.clone(),
1429 to: "-".to_string(),
1430 driver: "blocked",
1431 invocation: "none".to_string(),
1432 reason,
1433 });
1434 } else if initial != Some(row.state.as_str()) {
1435 ledger.push(LedgerEntry {
1438 task: row.id.clone(),
1439 from: initial.unwrap_or("").to_string(),
1440 to: row.state.clone(),
1441 driver: "callback-only",
1442 invocation: "none".to_string(),
1443 reason: "advanced without spawning work".to_string(),
1444 });
1445 } else if is_terminal_state(&row.state, machines.for_task(&parse_task_id(&row.id))) {
1446 ledger.push(LedgerEntry {
1447 task: row.id.clone(),
1448 from: row.state.clone(),
1449 to: "-".to_string(),
1450 driver: "terminal-at-start",
1451 invocation: "none".to_string(),
1452 reason: "already terminal".to_string(),
1453 });
1454 }
1455 }
1456 ledger
1457}
1458
1459fn build_invocations(
1461 records: &[LedgerRecord],
1462 workspace_root: &std::path::Path,
1463) -> Vec<InvocationRow> {
1464 records
1465 .iter()
1466 .map(|rec| InvocationRow {
1467 driver: rec.driver,
1468 task: rec.task.clone(),
1469 exit: match (&rec.outcome, rec.exit_code) {
1470 (LedgerOutcome::Cancelled, _) => "cancelled".to_string(),
1471 (LedgerOutcome::TimedOut, _) => "timed out".to_string(),
1472 (_, Some(code)) => format!("exit {code}"),
1473 (_, None) => "—".to_string(),
1474 },
1475 duration_ms: rec.duration_ms,
1476 log: relativize(&rec.log_path, workspace_root),
1477 })
1478 .collect()
1479}
1480
1481fn format_work(agents: u32, programs: u32, callback_only: u32) -> String {
1482 let mut parts = vec![format!("{agents} agents"), format!("{programs} programs")];
1483 if callback_only > 0 {
1484 parts.push(format!("{callback_only} callback-only"));
1485 }
1486 parts.join(" · ")
1487}
1488
1489fn marker_order(marker: Marker) -> u8 {
1490 match marker {
1491 Marker::Done => 0,
1492 Marker::Gate => 1,
1493 Marker::Attention => 2,
1494 Marker::Cancelled => 3,
1495 Marker::TerminalAtStart => 4,
1496 }
1497}
1498
1499fn format_duration_short(ms: u64) -> String {
1500 if ms < 60_000 {
1501 format!("{:.1}s", ms as f64 / 1000.0)
1502 } else {
1503 format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1000)
1504 }
1505}
1506
1507fn format_duration_long(d: std::time::Duration) -> String {
1508 let secs = d.as_secs();
1509 if secs < 60 {
1510 format!("{:.1}s", d.as_secs_f64())
1511 } else {
1512 format!("{}m{:02}s", secs / 60, secs % 60)
1513 }
1514}
1515
1516struct Palette {
1519 color: bool,
1520 reset: &'static str,
1521 bold: &'static str,
1522 dim: &'static str,
1523 red: &'static str,
1524}
1525
1526impl Palette {
1527 fn new(color: bool) -> Self {
1528 Self {
1529 color,
1530 reset: if color { RESET } else { "" },
1531 bold: if color { BOLD } else { "" },
1532 dim: if color { DIM } else { "" },
1533 red: if color { RED } else { "" },
1534 }
1535 }
1536
1537 fn color(&self, code: &'static str) -> &'static str {
1538 if self.color {
1539 code
1540 } else {
1541 ""
1542 }
1543 }
1544
1545 fn colored(&self, code: &'static str, text: &str) -> String {
1546 if self.color {
1547 format!("{code}{text}{RESET}")
1548 } else {
1549 text.to_string()
1550 }
1551 }
1552
1553 fn result_color(&self, result: &str) -> &'static str {
1554 if !self.color {
1555 return "";
1556 }
1557 if result.starts_with("stopped — ") {
1558 RED
1559 } else if result.starts_with("stopped") {
1560 YELLOW
1561 } else if result == "completed" {
1562 GREEN
1563 } else {
1564 ""
1565 }
1566 }
1567}
1568
1569#[cfg(test)]
1570mod run_summary_tests {
1571 use super::*;
1572
1573 fn machine() -> rhei_validator::StateMachine {
1574 rhei_validator::StateMachine::builtin_default()
1575 }
1576
1577 fn report(tasks: &[(&str, &str)]) -> RunSummaryReport {
1579 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1580 for (id, state) in tasks {
1581 md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1582 }
1583 let rhei = rhei_core::parse(&md).expect("plan parses");
1584 RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), test_stats(), "plan.rhei.md")
1585 }
1586
1587 fn test_stats() -> RunStats {
1590 RunStats {
1591 agents_spawned: 2,
1592 programs_spawned: 3,
1593 callback_only: 0,
1594 duration: Some(std::time::Duration::from_secs(5)),
1595 dashboard: None,
1596 run_id: "abc123".to_string(),
1597 started_at: Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_749_115_351)),
1598 workspace_root: std::path::PathBuf::from("examples/test"),
1599 command: "rhei run .".to_string(),
1600 parallel: 4,
1601 mode: "agent",
1602 initial_states: HashMap::new(),
1603 dry_run: false,
1604 }
1605 }
1606
1607 #[test]
1608 fn markers_classify_by_state_class() {
1609 let m = machine();
1610 assert_eq!(classify_marker("completed", &m), Marker::Done);
1611 assert_eq!(classify_marker("blocked", &m), Marker::Attention);
1612 assert_eq!(classify_marker("cancelled", &m), Marker::Cancelled);
1613 }
1614
1615 #[test]
1620 fn a_parent_waiting_on_its_subtree_reads_as_a_calm_pause() {
1621 let m = machine();
1622 let mut causes: HashMap<String, HaltCause> = HashMap::new();
1623 causes.insert(
1624 "plan.1".to_string(),
1625 HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (human-gate)".to_string() },
1626 );
1627 causes.insert("plan.2".to_string(), HaltCause::Stalled);
1628
1629 assert_eq!(classify_marker("pending", &m), Marker::Attention);
1631 assert_eq!(marker_for_task("plan.1", "pending", &m, &causes), Marker::Gate);
1632 assert_eq!(marker_for_task("plan.2", "pending", &m, &causes), Marker::Attention);
1633 assert_eq!(marker_for_task("plan.3", "pending", &m, &causes), Marker::Attention);
1634
1635 let (reason, _) = attention_reason(Marker::Gate, "plan.1", "pending", &causes);
1638 assert!(
1639 reason.contains("waiting on open descendant Task plan.1.1 (human-gate)"),
1640 "{reason}"
1641 );
1642 }
1643
1644 #[test]
1651 fn one_gate_under_three_ancestors_is_counted_once() {
1652 let rhei = rhei_core::parse(
1653 r#"# Rhei: Deep Subtree
1654---
1655structure:
1656 maxLevels: 4
1657---
1658
1659## Tasks
1660
1661### Task 1: Top
1662**State:** work
1663
1664#### Task 1.1: Middle
1665**State:** work
1666
1667##### Task 1.1.1: Inner
1668**State:** work
1669
1670###### Task 1.1.1.1: Gated leaf
1671**State:** human-gate
1672"#,
1673 )
1674 .expect("plan parses");
1675 let machine = rhei_validator::StateMachine::from_yaml_str(
1676 r#"name: t
1677version: 1
1678states:
1679 work:
1680 initial: true
1681 description: work
1682 human-gate:
1683 description: awaiting a human
1684 gating: true
1685 done:
1686 description: terminal
1687 final: true
1688transitions:
1689 - from: work
1690 to: done
1691 - from: human-gate
1692 to: done
1693"#,
1694 )
1695 .expect("valid state machine");
1696 let report = RunSummaryReport::build(
1697 &rhei,
1698 &rhei_validator::MachineSet::single(machine),
1699 &SummarySink::new(),
1700 test_stats(),
1701 "plan.rhei.md",
1702 );
1703
1704 assert_eq!(
1705 report.attention.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
1706 vec!["1.1.1.1"],
1707 "only the gate itself is halted work"
1708 );
1709
1710 let tty = report.render_tty(false);
1711 assert!(tty.contains("Attention 1 gated · 0 blocked"), "{tty}");
1712
1713 let markdown = report.render_markdown();
1714 assert!(markdown.contains("| could not advance | 1 |"), "{markdown}");
1715 assert_eq!(
1716 report.ledger.iter().filter(|e| e.driver == "blocked").count(),
1717 1,
1718 "one blocked ledger row, not one per ancestor"
1719 );
1720
1721 for id in ["1", "1.1", "1.1.1"] {
1724 let row = report.rows.iter().find(|r| r.id == id).expect("row present");
1725 assert_eq!(row.marker, Marker::Gate, "{id}");
1726 assert!(
1727 row.detail.as_deref().is_some_and(|d| d.contains("waiting on open descendant")),
1728 "{id}: {:?}",
1729 row.detail
1730 );
1731 }
1732 }
1733
1734 #[test]
1738 fn a_failed_parent_keeps_its_attention_marker() {
1739 let m = machine();
1740 let mut causes: HashMap<String, HaltCause> = HashMap::new();
1741 causes.insert(
1742 "plan.1".to_string(),
1743 HaltCause::WaitingOnDescendants { open: "Task plan.1.1 (pending)".to_string() },
1744 );
1745 assert_eq!(marker_for_task("plan.1", "blocked", &m, &causes), Marker::Attention);
1746 }
1747
1748 #[test]
1749 fn plain_render_lists_every_task_with_state() {
1750 let r = report(&[("1", "completed"), ("2", "blocked")]);
1751 let out = r.render_tty(false);
1752 assert!(out.contains("Run Report"), "{out}");
1753 assert!(out.contains("Test Plan"), "{out}");
1754 assert!(out.contains("completed"), "{out}");
1755 assert!(out.contains("blocked"), "{out}");
1756 assert!(!out.contains('\x1b'), "{out}");
1758 }
1759
1760 #[test]
1761 fn attention_block_surfaces_blocked_tasks() {
1762 let r = report(&[("1", "completed"), ("2", "blocked")]);
1763 let out = r.render_tty(false);
1764 assert!(out.contains("Attention"), "{out}");
1765 assert!(out.contains("1 blocked"), "{out}");
1766 assert!(out.contains("stopped for human attention"), "{out}");
1767 }
1768
1769 #[test]
1770 fn all_completed_reads_as_completed() {
1771 let r = report(&[("1", "completed"), ("2", "completed")]);
1772 let out = r.render_tty(false);
1773 assert!(out.contains("completed"), "{out}");
1774 assert!(!out.contains("Attention"), "{out}");
1775 }
1776
1777 #[test]
1778 fn color_render_emits_ansi() {
1779 let r = report(&[("1", "blocked")]);
1780 let out = r.render_tty(true);
1781 assert!(out.contains('\x1b'), "expected ANSI escapes");
1782 }
1783
1784 #[test]
1785 fn duration_formats_short_and_long() {
1786 assert_eq!(format_duration_short(200), "0.2s");
1787 assert_eq!(format_duration_short(8_100), "8.1s");
1788 assert_eq!(format_duration_short(65_000), "1m05s");
1789 assert_eq!(format_duration_long(std::time::Duration::from_secs(724)), "12m04s");
1790 }
1791
1792 fn report_with(tasks: &[(&str, &str)], stats: RunStats) -> RunSummaryReport {
1795 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1796 for (id, state) in tasks {
1797 md.push_str(&format!("### Task {id}: Task {id}\n**State:** {state}\n\n"));
1798 }
1799 let rhei = rhei_core::parse(&md).expect("plan parses");
1800 RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &SummarySink::new(), stats, "plan.rhei.md")
1801 }
1802
1803 #[test]
1804 fn markdown_report_has_all_sections() {
1805 let r = report(&[("1", "completed"), ("2", "blocked")]);
1806 let md = r.render_markdown();
1807 assert!(md.starts_with("# Run Report: Test Plan"), "{md}");
1808 assert!(md.contains("Run: 2025-"), "header carries the ISO start: {md}");
1809 assert!(md.contains("| Final states | Count |"), "{md}");
1810 assert!(md.contains("| Activity | Count |"), "{md}");
1811 assert!(md.contains("## Attention"), "{md}");
1812 assert!(md.contains("## Transition Ledger"), "{md}");
1813 assert!(md.contains("## Task Final States"), "{md}");
1814 }
1815
1816 #[test]
1817 fn run_id_is_stable_for_a_given_start() {
1818 let t = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(1_749_115_351_123_456);
1819 assert_eq!(short_run_id(t), short_run_id(t));
1820 assert_eq!(short_run_id(t).len(), 6);
1821 }
1822
1823 #[test]
1824 fn no_work_run_that_advanced_reads_differently() {
1825 let mut initial = HashMap::new();
1829 initial.insert("1".to_string(), "queued".to_string());
1830 let stats = RunStats {
1831 agents_spawned: 0,
1832 programs_spawned: 0,
1833 callback_only: 1,
1834 initial_states: initial,
1835 ..test_stats()
1836 };
1837 let r = report_with(&[("1", "completed")], stats);
1838 assert_eq!(r.result, "completed — no work spawned");
1839 let md = r.render_markdown();
1840 assert!(md.contains("No agent or program ran"), "{md}");
1841 assert!(md.contains("| 1 | queued | completed | callback-only |"), "{md}");
1843 }
1844
1845 #[test]
1846 fn terminal_at_start_task_is_marked_calm() {
1847 let mut initial = HashMap::new();
1848 initial.insert("done".to_string(), "completed".to_string());
1849 let stats = RunStats { initial_states: initial, ..test_stats() };
1850 let r = report_with(&[("done", "completed")], stats);
1851 assert_eq!(r.terminal_at_start, 1);
1852 let md = r.render_markdown();
1853 assert!(md.contains("terminal at start"), "{md}");
1854 assert!(md.contains("| done | completed | - | terminal-at-start |"), "{md}");
1856 }
1857
1858 #[test]
1859 fn write_to_runtime_emits_latest_and_history() {
1860 let dir = tempfile::tempdir().expect("tmpdir");
1861 let runtime = dir.path().join("runtime");
1862 let stats =
1863 RunStats { workspace_root: dir.path().to_path_buf(), ..test_stats() };
1864 let mut r = report_with(&[("1", "completed")], stats);
1865 r.write_to_runtime(&runtime).expect("write report");
1866 assert!(runtime.join("run-report.md").exists());
1867 assert_eq!(r.report_path.as_deref(), Some("runtime/run-report.md"));
1868 let history = std::fs::read_dir(runtime.join("run-reports"))
1869 .expect("history dir")
1870 .filter_map(Result::ok)
1871 .count();
1872 assert_eq!(history, 1, "one timestamped history entry written");
1873 }
1874
1875 #[test]
1876 fn dry_run_result_reads_as_preview() {
1877 let stats = RunStats { dry_run: true, ..test_stats() };
1878 let r = report_with(&[("1", "completed")], stats);
1879 assert_eq!(r.result, "dry run — no changes applied");
1880 assert!(r.render_markdown().contains("Result: dry run — no changes applied"));
1881 }
1882
1883 #[test]
1884 fn dashboard_pointer_gated_on_enabled_this_run() {
1885 let dir = tempfile::tempdir().expect("tmpdir");
1886 let runtime = dir.path().join("runtime");
1887 std::fs::create_dir_all(&runtime).unwrap();
1888 std::fs::write(runtime.join("dashboard.html"), "<html>").unwrap();
1889 assert_eq!(frozen_dashboard_relative_path(false, &runtime, dir.path()), None);
1892 assert_eq!(
1893 frozen_dashboard_relative_path(true, &runtime, dir.path()).as_deref(),
1894 Some("runtime/dashboard.html"),
1895 );
1896 }
1897
1898 #[test]
1899 fn md_cell_escapes_pipes_and_newlines() {
1900 assert_eq!(md_cell("a|b"), "a\\|b");
1901 assert_eq!(md_cell("line1\nline2"), "line1 line2");
1902 }
1903
1904 fn summary_with_spawn(task: &str, from: &str, to: &str, agent: bool) -> SummarySink {
1906 use rhei_tui::EventSink;
1907 let s = SummarySink::new();
1908 let log = std::path::PathBuf::from("runtime/logs/x.log");
1909 s.emit(rhei_tui::RunEvent::SlotAssigned {
1910 slot: 0,
1911 task: task.to_string(),
1912 from: from.to_string(),
1913 to: to.to_string(),
1914 agent: agent.then(|| "mock".to_string()),
1915 template_context: None,
1916 log_path: log.clone(),
1917 started_at: std::time::Instant::now(),
1918 wall_clock: std::time::SystemTime::now(),
1919 });
1920 s.emit(rhei_tui::RunEvent::SlotReleased {
1921 slot: 0,
1922 task: task.to_string(),
1923 from: from.to_string(),
1924 to: to.to_string(),
1925 log_path: log,
1926 outcome: rhei_tui::TaskOutcome::Completed,
1927 finished_at: std::time::Instant::now(),
1928 wall_clock: std::time::SystemTime::now(),
1929 exit_code: Some(0),
1930 duration_ms: 1_200,
1931 });
1932 s
1933 }
1934
1935 #[test]
1936 fn ledger_records_trailing_callback_advance_after_spawn() {
1937 let summary = summary_with_spawn("1", "build", "review", true);
1940 let stats = RunStats { initial_states: HashMap::new(), ..test_stats() };
1941 let mut md = String::from("# Rhei: Test Plan\n\n## Tasks\n\n");
1942 md.push_str("### Task 1: Task 1\n**State:** completed\n\n");
1943 let rhei = rhei_core::parse(&md).expect("plan parses");
1944 let report = RunSummaryReport::build(&rhei, &rhei_validator::MachineSet::single(machine()), &summary, stats, "plan.rhei.md");
1945 let md = report.render_markdown();
1946 assert!(md.contains("| 1 | build | review | agent |"), "{md}");
1948 assert!(md.contains("| 1 | review | completed | callback-only |"), "{md}");
1949 }
1950}