返回 CodeWhale
workflow_panel.rs
根目录 / crates / tui / src / tui / widgets / workflow_panel.rs
1 //! WorkflowPanel — one workflow run's state, reduced from its event stream.
2 //!
3 //! Issue #4121 (CODEWHALE_0_8_68 §2.4). Progress lives off the transcript: the
4 //! workbar under the composer (`workbar.rs`) paints one row per run from this
5 //! state, and `/workflows` holds the detail. Events are applied through
6 //! [`WorkflowPanelEvent`].
7 //!
8 //! Issue #4122 routes the same event stream into a compact history card that
9 //! reuses this state machine: collapsed summarizes lifecycle/children/phases/
10 //! failures/elapsed; expanded adds phase/child summaries, artifact links,
11 //! final result, and failure details. Direct sub-agent cards share helpers
12 //! from this module where practical.
13
14 use std::path::PathBuf;
15 use std::time::{SystemTime, UNIX_EPOCH};
16
17 use ratatui::style::{Modifier, Style};
18 use ratatui::text::{Line, Span};
19 use serde_json::{Value, json};
20 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
21
22 use crate::tui::ui_text::truncate_line_to_width;
23 use codewhale_localization::{Locale, MessageId, tr};
24 use codewhale_palette as palette;
25
26 /// Maximum phase summary chips shown in the expanded body.
27 const MAX_PHASE_SUMMARY: usize = 6;
28 /// Newest rejected dispatches retained by the panel. The workflow journal is
29 /// the durable, unbounded source of truth; this is only a compact UI tail.
30 const MAX_DISPATCH_FAILURES_RETAINED: usize = 12;
31 /// Rejected dispatches shown at once in the live panel/history body.
32 const MAX_VISIBLE_DISPATCH_FAILURES: usize = 3;
33 /// Columns the transcript card header gives its summary
34 /// (`history::constants::TOOL_HEADER_SUMMARY_LIMIT`); the finish line's facts
35 /// are fitted to it so the header never cuts the counts.
36 const FINISH_FACTS_COLS: usize = 72;
37 /// The title keeps at least this much of itself on the finish line.
38 const MIN_FINISH_TITLE_COLS: usize = 16;
39
40 /// Lifecycle of the active (or most recently completed) workflow run.
41 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 pub enum WorkflowPanelLifecycle {
43 Pending,
44 Running,
45 Succeeded,
46 /// The workflow returned usable output but one or more task slots failed.
47 Degraded,
48 Failed,
49 Cancelled,
50 }
51
52 impl WorkflowPanelLifecycle {
53 #[must_use]
54 pub fn is_running(self) -> bool {
55 matches!(self, Self::Running | Self::Pending)
56 }
57
58 #[must_use]
59 pub fn is_terminal(self) -> bool {
60 matches!(
61 self,
62 Self::Succeeded | Self::Degraded | Self::Failed | Self::Cancelled
63 )
64 }
65
66 #[must_use]
67 pub fn label(self) -> &'static str {
68 match self {
69 Self::Pending => "pending",
70 Self::Running => "running",
71 Self::Succeeded => "success",
72 Self::Degraded => "degraded",
73 Self::Failed => "failed",
74 Self::Cancelled => "cancelled",
75 }
76 }
77
78 fn display_label(self, locale: Locale) -> std::borrow::Cow<'static, str> {
79 match self {
80 Self::Degraded => tr(locale, MessageId::WorkflowStatusDegraded),
81 other => std::borrow::Cow::Borrowed(other.label()),
82 }
83 }
84
85 fn color(self) -> ratatui::style::Color {
86 match self {
87 Self::Pending => palette::TEXT_MUTED,
88 // A healthy run is accent, not attention: warning/error colours
89 // are reserved for states that need the user (#6503).
90 Self::Running => palette::WHALE_ACTION,
91 Self::Succeeded => palette::STATUS_SUCCESS,
92 Self::Degraded => palette::STATUS_WARNING,
93 Self::Failed => palette::STATUS_ERROR,
94 Self::Cancelled => palette::TEXT_MUTED,
95 }
96 }
97 }
98
99 /// Per-task / per-worker row status.
100 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 pub enum WorkflowRowStatus {
102 Pending,
103 Running,
104 Waiting,
105 Succeeded,
106 Failed,
107 Cancelled,
108 SchemaFailed,
109 }
110
111 impl WorkflowRowStatus {
112 #[must_use]
113 pub fn label(self) -> &'static str {
114 match self {
115 Self::Pending => "pending",
116 Self::Running => "running",
117 Self::Waiting => "waiting",
118 Self::Succeeded => "done",
119 Self::Failed => "failed",
120 Self::Cancelled => "cancelled",
121 Self::SchemaFailed => "schema",
122 }
123 }
124
125 /// Localized display variant of [`Self::label`]. `label()` stays
126 /// English because it doubles as the machine-readable `status` token in
127 /// [`WorkflowPanel::to_run_json`]; this method is for rendered rows only.
128 #[must_use]
129 pub fn display_label(self, locale: Locale) -> std::borrow::Cow<'static, str> {
130 match self {
131 Self::Waiting => tr(locale, MessageId::WorkflowStatusWaiting),
132 other => std::borrow::Cow::Borrowed(other.label()),
133 }
134 }
135
136 #[must_use]
137 pub fn is_running(self) -> bool {
138 matches!(self, Self::Pending | Self::Running | Self::Waiting)
139 }
140
141 #[must_use]
142 pub fn is_failure(self) -> bool {
143 matches!(self, Self::Failed | Self::SchemaFailed)
144 }
145
146 #[must_use]
147 pub fn is_cancel(self) -> bool {
148 matches!(self, Self::Cancelled)
149 }
150
151 fn color(self) -> ratatui::style::Color {
152 match self {
153 Self::Pending => palette::TEXT_MUTED,
154 // Running is the normal state of a live row, so it reads neutral
155 // (#6503). Waiting covers `needs_user` / `blocked`, which do want
156 // attention; failure red stays reserved for actual failure.
157 Self::Running => palette::TEXT_TOOL_OUTPUT,
158 Self::Waiting => palette::STATUS_WARNING,
159 Self::Succeeded => palette::STATUS_SUCCESS,
160 Self::Failed | Self::SchemaFailed => palette::STATUS_ERROR,
161 Self::Cancelled => palette::TEXT_MUTED,
162 }
163 }
164
165 fn from_ir_status(status: &str) -> Self {
166 match status {
167 "succeeded" | "completed" | "success" | "done" => Self::Succeeded,
168 "failed" | "error" => Self::Failed,
169 "cancelled" | "canceled" => Self::Cancelled,
170 "budget_exceeded" => Self::Failed,
171 "running" => Self::Running,
172 "waiting" | "blocked" | "needs_user" => Self::Waiting,
173 "pending" => Self::Pending,
174 other if other.contains("schema") => Self::SchemaFailed,
175 _ => Self::Failed,
176 }
177 }
178 }
179
180 /// Closed route-source vocabulary minted by the spawn resolver. Persisted
181 /// journals are untrusted input: an unrecognized value stays unknown rather
182 /// than becoming UI copy (#4039).
183 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
184 pub enum WorkflowRouteSource {
185 TaskModel,
186 TaskModelStrength,
187 AgentProfileModel,
188 AgentProfileLoadout,
189 RoleDefault,
190 RunModel,
191 }
192
193 impl WorkflowRouteSource {
194 fn parse(value: &str) -> Option<Self> {
195 match value.trim() {
196 "task.model" => Some(Self::TaskModel),
197 "task.model_strength" => Some(Self::TaskModelStrength),
198 "agent_profile.model" => Some(Self::AgentProfileModel),
199 "agent_profile.loadout" => Some(Self::AgentProfileLoadout),
200 "role.default" => Some(Self::RoleDefault),
201 "run.model" => Some(Self::RunModel),
202 _ => None,
203 }
204 }
205
206 const fn as_str(self) -> &'static str {
207 match self {
208 Self::TaskModel => "task.model",
209 Self::TaskModelStrength => "task.model_strength",
210 Self::AgentProfileModel => "agent_profile.model",
211 Self::AgentProfileLoadout => "agent_profile.loadout",
212 Self::RoleDefault => "role.default",
213 Self::RunModel => "run.model",
214 }
215 }
216 }
217
218 /// Closed token provenance carried by a terminal usage receipt.
219 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
220 pub enum WorkflowTokenSource {
221 ProviderReported,
222 Estimated,
223 }
224
225 impl WorkflowTokenSource {
226 fn parse(value: &str) -> Option<Self> {
227 match value.trim() {
228 "provider_reported" => Some(Self::ProviderReported),
229 "estimated" => Some(Self::Estimated),
230 _ => None,
231 }
232 }
233
234 const fn as_str(self) -> &'static str {
235 match self {
236 Self::ProviderReported => "provider_reported",
237 Self::Estimated => "estimated",
238 }
239 }
240 }
241
242 /// Immutable route captured by the task-started event (#4039, #5305).
243 #[derive(Debug, Clone, Default, PartialEq, Eq)]
244 pub struct WorkflowRowRoute {
245 pub child_route: Option<crate::tools::subagent::ChildRouteReceipt>,
246 pub role: Option<String>,
247 pub provider: Option<String>,
248 pub model: Option<String>,
249 pub requested_reasoning: Option<String>,
250 pub effective_reasoning: Option<String>,
251 pub route_source: Option<WorkflowRouteSource>,
252 }
253
254 impl WorkflowRowRoute {
255 fn from_json(value: &Value) -> Self {
256 let child_route: Option<crate::tools::subagent::ChildRouteReceipt> = value
257 .get("child_route")
258 .cloned()
259 .and_then(|value| serde_json::from_value(value).ok());
260 let receipt = child_route.as_ref();
261 Self {
262 role: receipt
263 .map(|receipt| receipt.canonical_role.clone())
264 .or_else(|| opt_str(value, "resolved_role"))
265 .or_else(|| opt_str(value, "role")),
266 provider: receipt
267 .map(|receipt| receipt.provider_id.clone())
268 .or_else(|| opt_str(value, "resolved_provider"))
269 .or_else(|| opt_str(value, "provider")),
270 model: receipt
271 .map(|receipt| receipt.model_id.clone())
272 .or_else(|| opt_str(value, "resolved_model")),
273 requested_reasoning: receipt
274 .map(|receipt| receipt.requested_reasoning.clone())
275 .or_else(|| opt_str(value, "requested_reasoning"))
276 .or_else(|| opt_str(value, "thinking")),
277 effective_reasoning: receipt
278 .and_then(|receipt| receipt.effective_reasoning.clone())
279 .or_else(|| opt_str(value, "effective_reasoning")),
280 route_source: receipt
281 .and_then(|receipt| WorkflowRouteSource::parse(&receipt.route_source))
282 .or_else(|| {
283 opt_str(value, "route_source")
284 .as_deref()
285 .and_then(WorkflowRouteSource::parse)
286 }),
287 child_route,
288 }
289 }
290
291 fn field(value: Option<&String>, locale: Locale) -> String {
292 value
293 .map(String::as_str)
294 .map(crate::tui::app::bound_agent_activity_text)
295 .map(|value| {
296 value
297 .chars()
298 .map(|ch| if ch.is_control() { ' ' } else { ch })
299 .collect::<String>()
300 .split_whitespace()
301 .collect::<Vec<_>>()
302 .join(" ")
303 })
304 .filter(|value| !value.is_empty())
305 .unwrap_or_else(|| tr(locale, MessageId::WorkflowReceiptUnknown).into_owned())
306 }
307 }
308
309 /// Optional terminal usage receipt from `task_completed` (#4039).
310 #[derive(Debug, Clone, Default, PartialEq, Eq)]
311 pub struct WorkflowRowUsage {
312 pub input_tokens: Option<u64>,
313 pub output_tokens: Option<u64>,
314 pub total_tokens: Option<u64>,
315 pub tool_calls: Option<u32>,
316 pub duration_ms: Option<u64>,
317 pub token_source: Option<WorkflowTokenSource>,
318 }
319
320 impl WorkflowRowUsage {
321 fn token_total(&self) -> Option<u64> {
322 self.total_tokens
323 .or_else(|| match (self.input_tokens, self.output_tokens) {
324 (Some(input), Some(output)) => Some(input.saturating_add(output)),
325 _ => None,
326 })
327 }
328
329 fn token_source_label(&self, locale: Locale) -> String {
330 match self.token_source {
331 Some(WorkflowTokenSource::ProviderReported) => {
332 tr(locale, MessageId::WorkflowReceiptProviderReported).into_owned()
333 }
334 Some(WorkflowTokenSource::Estimated) => {
335 tr(locale, MessageId::WorkflowReceiptEstimated).into_owned()
336 }
337 None => tr(locale, MessageId::WorkflowReceiptUnknown).into_owned(),
338 }
339 }
340 }
341
342 /// One worker/task row under a phase.
343 #[derive(Debug, Clone, PartialEq, Eq)]
344 pub struct WorkflowPanelRow {
345 pub task_id: String,
346 pub label: String,
347 pub profile: Option<String>,
348 pub model: Option<String>,
349 pub strength: Option<String>,
350 pub worktree: bool,
351 pub workspace: Option<PathBuf>,
352 pub status: WorkflowRowStatus,
353 pub started_at_ms: u64,
354 pub completed_at_ms: Option<u64>,
355 pub error: Option<String>,
356 pub schema_error: Option<String>,
357 pub route: WorkflowRowRoute,
358 pub usage: Option<WorkflowRowUsage>,
359 }
360
361 /// One lane gate status line surfaced by the Workflow runtime (#4179).
362 #[derive(Debug, Clone, PartialEq, Eq)]
363 pub struct WorkflowPanelGateLine {
364 pub gate_id: String,
365 pub role: Option<String>,
366 pub gate: Option<String>,
367 pub state: String,
368 pub blocked_role: Option<String>,
369 pub blocked_reason: Option<String>,
370 }
371
372 /// One ordered phase group.
373 #[derive(Debug, Clone, PartialEq, Eq)]
374 pub struct WorkflowPanelPhase {
375 pub title: String,
376 pub rows: Vec<WorkflowPanelRow>,
377 }
378
379 /// One workflow task dispatch rejected before a child agent existed.
380 ///
381 /// This deliberately does not reuse [`WorkflowPanelRow`]: counting a rejected
382 /// launch as a child would make the panel's child/receipt totals dishonest.
383 #[derive(Debug, Clone, PartialEq, Eq)]
384 pub struct WorkflowPanelDispatchFailure {
385 pub label: Option<String>,
386 pub phase: Option<String>,
387 pub message: String,
388 pub at_ms: u64,
389 }
390
391 impl WorkflowPanelDispatchFailure {
392 fn bounded(label: Option<String>, phase: Option<String>, message: String, at_ms: u64) -> Self {
393 let bounded = |value: String| {
394 crate::tui::app::bound_agent_activity_text(&value)
395 .chars()
396 .map(|ch| if ch.is_control() { ' ' } else { ch })
397 .collect::<String>()
398 .split_whitespace()
399 .collect::<Vec<_>>()
400 .join(" ")
401 };
402 let label = label.map(bounded).filter(|value| !value.is_empty());
403 let phase = phase.map(bounded).filter(|value| !value.is_empty());
404 let message = bounded(message);
405 Self {
406 label,
407 phase,
408 message,
409 at_ms,
410 }
411 }
412 }
413
414 impl WorkflowPanelPhase {
415 fn new(title: impl Into<String>) -> Self {
416 Self {
417 title: title.into(),
418 rows: Vec::new(),
419 }
420 }
421
422 fn counts(&self) -> (usize, usize, usize, usize) {
423 let mut done = 0usize;
424 let mut running = 0usize;
425 let mut failed = 0usize;
426 let mut cancelled = 0usize;
427 for row in &self.rows {
428 match row.status {
429 WorkflowRowStatus::Succeeded => done += 1,
430 WorkflowRowStatus::Running
431 | WorkflowRowStatus::Pending
432 | WorkflowRowStatus::Waiting => running += 1,
433 WorkflowRowStatus::Failed | WorkflowRowStatus::SchemaFailed => failed += 1,
434 WorkflowRowStatus::Cancelled => cancelled += 1,
435 }
436 }
437 (done, running, failed, cancelled)
438 }
439
440 /// Labelled non-zero counts, e.g. `4 running · 1 done` (#6503). Replaces
441 /// the unlabelled `[0✓ 5… 0! 0⊘]` glyph counters.
442 fn counts_text(&self, locale: Locale, separator: &str) -> String {
443 let (done, running, failed, cancelled) = self.counts();
444 let counts = [
445 (running, MessageId::WorkflowCountRunning),
446 (done, MessageId::WorkflowCountDone),
447 (failed, MessageId::WorkflowCountFailed),
448 (cancelled, MessageId::WorkflowCountCancelled),
449 ]
450 .into_iter()
451 .filter(|(count, _)| *count > 0)
452 .map(|(count, id)| count_text(locale, id, count))
453 .collect::<Vec<_>>();
454 if counts.is_empty() {
455 tr(locale, MessageId::WorkflowNoTasksYet).into_owned()
456 } else {
457 counts.join(separator)
458 }
459 }
460 }
461
462 /// Events the panel understands. Mirrors the tool-side `WorkflowUiEvent`
463 /// shape so #4122 can forward JSON without re-encoding.
464 #[derive(Debug, Clone, PartialEq, Eq)]
465 pub enum WorkflowPanelEvent {
466 RunStarted {
467 run_id: String,
468 workflow_id: Option<String>,
469 workflow_goal: Option<String>,
470 source_path: Option<PathBuf>,
471 token_budget: Option<u64>,
472 at_ms: u64,
473 },
474 RunCompleted {
475 status: WorkflowPanelLifecycle,
476 error: Option<String>,
477 at_ms: u64,
478 },
479 RunCancelled {
480 reason: String,
481 at_ms: u64,
482 },
483 PhaseStarted {
484 title: String,
485 at_ms: u64,
486 },
487 TaskStarted {
488 task_id: String,
489 label: Option<String>,
490 profile: Option<String>,
491 model: Option<String>,
492 strength: Option<String>,
493 resolved_model: Option<String>,
494 worktree: bool,
495 workspace: Option<PathBuf>,
496 /// Launch receipt carried by this event (#4039).
497 route: Box<WorkflowRowRoute>,
498 at_ms: u64,
499 },
500 TaskCompleted {
501 task_id: String,
502 status: WorkflowRowStatus,
503 /// Terminal usage receipt carried by this event, if any (#4039).
504 usage: Option<WorkflowRowUsage>,
505 /// Why a failed or cancelled task ended (`reason` on the wire). The
506 /// run's own error is often an aggregate ("all 2 task(s) failed"); the
507 /// task's reason is the cause a person can act on.
508 reason: Option<String>,
509 at_ms: u64,
510 },
511 GateUpdated {
512 gate_id: String,
513 role: Option<String>,
514 gate: Option<String>,
515 state: String,
516 blocked_role: Option<String>,
517 blocked_reason: Option<String>,
518 at_ms: u64,
519 },
520 TaskSchemaValidationFailed {
521 task_id: String,
522 message: String,
523 at_ms: u64,
524 },
525 TaskDispatchFailed {
526 label: Option<String>,
527 phase: Option<String>,
528 message: String,
529 at_ms: u64,
530 },
531 BudgetUpdated {
532 total: Option<u64>,
533 spent: u64,
534 remaining: Option<u64>,
535 at_ms: u64,
536 },
537 }
538
539 impl WorkflowPanelEvent {
540 /// Parse one flattened tool UI event (`{"type":"…", …}`).
541 pub fn from_json_value(value: &Value) -> Option<Self> {
542 let event_type = value.get("type")?.as_str()?;
543 let at_ms = value
544 .get("at_ms")
545 .and_then(Value::as_u64)
546 .unwrap_or_else(now_ms);
547 match event_type {
548 "run_started" => Some(Self::RunStarted {
549 run_id: value
550 .get("run_id")
551 .and_then(Value::as_str)
552 .unwrap_or("workflow")
553 .to_string(),
554 workflow_id: opt_str(value, "workflow_id"),
555 workflow_goal: opt_str(value, "workflow_goal"),
556 source_path: opt_str(value, "source_path").map(PathBuf::from),
557 token_budget: value.get("token_budget").and_then(Value::as_u64),
558 at_ms,
559 }),
560 "run_completed" => {
561 // A terminal receipt without a readable status is not
562 // evidence of success; it fails closed like an unknown one.
563 let status = value
564 .get("status")
565 .and_then(Value::as_str)
566 .map(lifecycle_from_status)
567 .unwrap_or(WorkflowPanelLifecycle::Failed);
568 Some(Self::RunCompleted {
569 status,
570 error: opt_str(value, "error"),
571 at_ms,
572 })
573 }
574 "run_cancelled" => Some(Self::RunCancelled {
575 reason: opt_str(value, "reason").unwrap_or_else(|| "cancelled".to_string()),
576 at_ms,
577 }),
578 "phase_started" => Some(Self::PhaseStarted {
579 title: opt_str(value, "title").unwrap_or_else(|| "Phase".to_string()),
580 at_ms,
581 }),
582 "task_started" => Some(Self::TaskStarted {
583 task_id: opt_str(value, "task_id")?,
584 // Prefer typed workflow metadata over generic label so rows
585 // never fall back to prompt parsing (#4119).
586 label: opt_str(value, "workflow_task_label").or_else(|| opt_str(value, "label")),
587 profile: opt_str(value, "profile"),
588 model: opt_str(value, "model").or_else(|| opt_str(value, "resolved_model")),
589 strength: opt_str(value, "strength"),
590 resolved_model: opt_str(value, "resolved_model"),
591 worktree: value
592 .get("worktree")
593 .and_then(Value::as_bool)
594 .unwrap_or(false),
595 workspace: opt_str(value, "workspace").map(PathBuf::from),
596 route: Box::new(WorkflowRowRoute::from_json(value)),
597 at_ms,
598 }),
599 "task_completed" => {
600 // Like `run_completed`: a missing status, or a completion
601 // receipt that still says running/pending, is contradictory
602 // and fails closed rather than leaving a live-looking row.
603 let status = value
604 .get("status")
605 .and_then(Value::as_str)
606 .map(WorkflowRowStatus::from_ir_status)
607 .filter(|status| {
608 !matches!(
609 status,
610 WorkflowRowStatus::Running | WorkflowRowStatus::Pending
611 )
612 })
613 .unwrap_or(WorkflowRowStatus::Failed);
614 Some(Self::TaskCompleted {
615 task_id: opt_str(value, "task_id")?,
616 status,
617 usage: value.get("usage").and_then(usage_from_json),
618 reason: opt_str(value, "reason").or_else(|| opt_str(value, "error")),
619 at_ms,
620 })
621 }
622 "gate_updated" => Some(Self::GateUpdated {
623 gate_id: opt_str(value, "gate_id")?,
624 role: opt_str(value, "role"),
625 gate: opt_str(value, "gate"),
626 state: opt_str(value, "state").unwrap_or_else(|| "pending".to_string()),
627 blocked_role: opt_str(value, "blocked_role"),
628 blocked_reason: opt_str(value, "blocked_reason"),
629 at_ms,
630 }),
631 "task_schema_validation_failed" => Some(Self::TaskSchemaValidationFailed {
632 task_id: opt_str(value, "task_id")?,
633 message: opt_str(value, "message").unwrap_or_else(|| "schema failed".to_string()),
634 at_ms,
635 }),
636 "task_dispatch_failed" => Some(Self::TaskDispatchFailed {
637 label: opt_str(value, "label"),
638 phase: opt_str(value, "phase"),
639 message: opt_str(value, "message").unwrap_or_default(),
640 at_ms,
641 }),
642 "budget_updated" => Some(Self::BudgetUpdated {
643 total: value.get("total").and_then(Value::as_u64),
644 spent: value.get("spent").and_then(Value::as_u64).unwrap_or(0),
645 remaining: value.get("remaining").and_then(Value::as_u64),
646 at_ms,
647 }),
648 // Logs are intentionally not surfaced in the panel body — they
649 // would re-flood the surface the panel exists to protect.
650 "log" => None,
651 _ => None,
652 }
653 }
654 }
655
656 /// Collapsible workflow activity panel.
657 #[derive(Debug, Clone)]
658 pub struct WorkflowPanel {
659 pub run_id: String,
660 pub label: String,
661 pub lifecycle: WorkflowPanelLifecycle,
662 pub phases: Vec<WorkflowPanelPhase>,
663 pub selected_phase: usize,
664 pub gates: Vec<WorkflowPanelGateLine>,
665 /// Newest rejected launches. These are run failures, not child rows.
666 pub dispatch_failures: Vec<WorkflowPanelDispatchFailure>,
667 /// Monotonic count, including failures older than the retained UI tail.
668 pub dispatch_failure_count: usize,
669 pub budget_total: Option<u64>,
670 pub budget_spent: u64,
671 pub budget_remaining: Option<u64>,
672 pub started_at_ms: u64,
673 pub completed_at_ms: Option<u64>,
674 pub error: Option<String>,
675 /// Optional final result / verification summary for the history card.
676 pub result_summary: Option<String>,
677 /// Source script path or other durable artifact pointer.
678 pub source_path: Option<PathBuf>,
679 /// Spillover / full-output path when the tool result was large.
680 pub spillover_path: Option<PathBuf>,
681 /// UI locale for rendered copy. Defaults to English; hosts with app
682 /// access set it after construction (#4057 wave 2).
683 pub locale: Locale,
684 /// Whether the transcript already carries this run's finish line. The live
685 /// stream and the tool-complete hydration can both deliver the terminal
686 /// event; the line is written once.
687 pub finish_announced: bool,
688 /// Direct-agent cards reuse the Workflow history layout but do not carry a
689 /// Workflow launch receipt. Keep that distinction explicit so the shared
690 /// renderer never invents unknown Workflow provenance for them (#4039).
691 show_workflow_receipts: bool,
692 }
693
694 /// Extra fields the history card can show that are not part of the live panel
695 /// progress surface (artifact links, final result text).
696 #[derive(Debug, Clone, Default)]
697 pub struct WorkflowHistoryExtras {
698 pub result_summary: Option<String>,
699 pub source_path: Option<PathBuf>,
700 pub spillover_path: Option<PathBuf>,
701 pub verification_summary: Option<String>,
702 }
703
704 impl WorkflowPanel {
705 #[must_use]
706 pub fn new(run_id: impl Into<String>, label: impl Into<String>, at_ms: u64) -> Self {
707 Self {
708 run_id: run_id.into(),
709 label: label.into(),
710 lifecycle: WorkflowPanelLifecycle::Running,
711 phases: Vec::new(),
712 selected_phase: 0,
713 gates: Vec::new(),
714 dispatch_failures: Vec::new(),
715 dispatch_failure_count: 0,
716 budget_total: None,
717 budget_spent: 0,
718 budget_remaining: None,
719 started_at_ms: at_ms,
720 completed_at_ms: None,
721 error: None,
722 result_summary: None,
723 source_path: None,
724 spillover_path: None,
725 locale: Locale::En,
726 finish_announced: false,
727 show_workflow_receipts: true,
728 }
729 }
730
731 /// Hydrate panel state from a workflow tool JSON payload (run record or a
732 /// snapshot produced by [`Self::to_run_json`]). Prefers the typed `events`
733 /// array when present; falls back to summary + phase fields.
734 #[must_use]
735 pub fn from_run_json(value: &Value) -> Option<Self> {
736 if value.get("action").and_then(Value::as_str) == Some("status") {
737 return None;
738 }
739 let run_id = value
740 .get("run_id")
741 .and_then(Value::as_str)
742 .filter(|s| !s.is_empty())?
743 .to_string();
744 let label = value
745 .get("workflow_goal")
746 .and_then(Value::as_str)
747 .or_else(|| value.get("workflow_id").and_then(Value::as_str))
748 .filter(|s| !s.trim().is_empty())
749 .unwrap_or(&run_id)
750 .to_string();
751 let at_ms = value
752 .get("started_at_ms")
753 .and_then(Value::as_u64)
754 .unwrap_or(0);
755 let mut panel = Self::new(run_id.clone(), label.clone(), at_ms);
756
757 if let Some(events) = value.get("events").and_then(Value::as_array) {
758 for event in events {
759 let mut event = event.clone();
760 if let Some(obj) = event.as_object_mut() {
761 obj.insert("run_id".to_string(), Value::String(run_id.clone()));
762 }
763 panel.apply_json_event(&event);
764 }
765 } else if let Some(phases) = value.get("phases").and_then(Value::as_array) {
766 for phase_val in phases {
767 let title = phase_val
768 .get("title")
769 .and_then(Value::as_str)
770 .unwrap_or("Work");
771 panel.phases.push(WorkflowPanelPhase::new(title));
772 let phase_idx = panel.phases.len() - 1;
773 if let Some(rows) = phase_val.get("rows").and_then(Value::as_array) {
774 for row in rows {
775 let task_id = row
776 .get("task_id")
777 .and_then(Value::as_str)
778 .unwrap_or("task")
779 .to_string();
780 let status = row
781 .get("status")
782 .and_then(Value::as_str)
783 .map(WorkflowRowStatus::from_ir_status)
784 .unwrap_or(WorkflowRowStatus::Pending);
785 panel.phases[phase_idx].rows.push(WorkflowPanelRow {
786 task_id: task_id.clone(),
787 label: row
788 .get("label")
789 .and_then(Value::as_str)
790 .unwrap_or(&task_id)
791 .to_string(),
792 profile: opt_str(row, "profile"),
793 model: opt_str(row, "model"),
794 strength: opt_str(row, "strength"),
795 worktree: row
796 .get("worktree")
797 .and_then(Value::as_bool)
798 .unwrap_or(false),
799 workspace: opt_str(row, "workspace").map(PathBuf::from),
800 status,
801 started_at_ms: row
802 .get("started_at_ms")
803 .and_then(Value::as_u64)
804 .unwrap_or(at_ms),
805 completed_at_ms: row.get("completed_at_ms").and_then(Value::as_u64),
806 error: opt_str(row, "error"),
807 schema_error: opt_str(row, "schema_error"),
808 route: WorkflowRowRoute::from_json(row),
809 usage: row.get("usage").and_then(usage_from_json),
810 });
811 }
812 }
813 }
814 if !panel.phases.is_empty() {
815 panel.selected_phase = panel.phases.len() - 1;
816 }
817 } else if let Some(child_count) =
818 value
819 .get("child_count")
820 .and_then(Value::as_u64)
821 .or_else(|| {
822 value
823 .get("child_ids")
824 .and_then(Value::as_array)
825 .map(|a| a.len() as u64)
826 })
827 {
828 // Bare summary without events: synthesize a Work phase so child
829 // count still surfaces on the history card.
830 if child_count > 0 {
831 let mut phase = WorkflowPanelPhase::new("Work");
832 for i in 0..child_count {
833 let id = value
834 .get("child_ids")
835 .and_then(Value::as_array)
836 .and_then(|ids| ids.get(i as usize))
837 .and_then(Value::as_str)
838 .map(str::to_string)
839 .unwrap_or_else(|| format!("child-{i}"));
840 phase.rows.push(WorkflowPanelRow {
841 task_id: id.clone(),
842 label: id,
843 profile: None,
844 model: None,
845 strength: None,
846 worktree: false,
847 workspace: None,
848 status: WorkflowRowStatus::Succeeded,
849 started_at_ms: at_ms,
850 completed_at_ms: value.get("completed_at_ms").and_then(Value::as_u64),
851 error: None,
852 schema_error: None,
853 // A bare child-count summary carries no receipt at all;
854 // the row must show that rather than infer one (#4039).
855 route: WorkflowRowRoute::default(),
856 usage: Some(WorkflowRowUsage::default()),
857 });
858 }
859 panel.phases.push(phase);
860 }
861 }
862
863 panel.merge_dispatch_failures_from_run_json(value);
864
865 if let Some(gates) = value
866 .get("gate_status")
867 .or_else(|| value.get("gates"))
868 .and_then(Value::as_array)
869 {
870 for gate in gates {
871 if let Some(gate_id) = opt_str(gate, "gate_id") {
872 panel.upsert_gate(WorkflowPanelGateLine {
873 gate_id,
874 role: opt_str(gate, "role"),
875 gate: opt_str(gate, "gate"),
876 state: opt_str(gate, "state").unwrap_or_else(|| "pending".to_string()),
877 blocked_role: opt_str(gate, "blocked_role"),
878 blocked_reason: opt_str(gate, "blocked_reason"),
879 });
880 }
881 }
882 }
883
884 if let Some(status) = value.get("status").and_then(Value::as_str) {
885 let life = lifecycle_from_status(status);
886 if life.is_terminal() {
887 panel.lifecycle = life;
888 panel.completed_at_ms = value
889 .get("completed_at_ms")
890 .and_then(Value::as_u64)
891 .or(panel.completed_at_ms);
892 } else if panel.lifecycle.is_running() {
893 panel.lifecycle = life;
894 }
895 }
896 if let Some(error) = opt_str(value, "error") {
897 panel.error = Some(error);
898 }
899 if let Some(spent) = value.get("budget_spent").and_then(Value::as_u64) {
900 panel.budget_spent = spent;
901 }
902 if let Some(total) = value
903 .get("token_budget")
904 .or_else(|| value.get("budget_total"))
905 .and_then(Value::as_u64)
906 {
907 panel.budget_total = Some(total);
908 }
909 if let Some(remaining) = value.get("budget_remaining").and_then(Value::as_u64) {
910 panel.budget_remaining = Some(remaining);
911 }
912 // Apply extras after events so RunStarted reset does not wipe them.
913 if panel.source_path.is_none() {
914 panel.source_path = opt_str(value, "source_path").map(PathBuf::from);
915 }
916 if panel.result_summary.is_none() {
917 panel.result_summary = value
918 .get("result")
919 .and_then(summarize_result_value)
920 .or_else(|| opt_str(value, "result_summary"));
921 }
922 if let Some(verification) = value.get("verification")
923 && let Some(summary) = verification.get("summary").and_then(Value::as_str)
924 {
925 let trimmed = summary.trim();
926 if !trimmed.is_empty() {
927 panel.result_summary = Some(match panel.result_summary.take() {
928 Some(existing) => format!("{existing} · verify: {trimmed}"),
929 None => format!("verify: {trimmed}"),
930 });
931 }
932 }
933 // Prefer the goal label from the payload when events used a fallback.
934 if !label.is_empty() && panel.label == run_id {
935 panel.label = label;
936 }
937 Some(panel)
938 }
939
940 /// Snapshot panel state into a JSON blob suitable for the history cell
941 /// (and re-hydration via [`Self::from_run_json`]).
942 #[must_use]
943 pub fn to_run_json(&self) -> Value {
944 let status = match self.lifecycle {
945 WorkflowPanelLifecycle::Pending => "pending",
946 WorkflowPanelLifecycle::Running => "running",
947 WorkflowPanelLifecycle::Succeeded => "completed",
948 WorkflowPanelLifecycle::Degraded => "degraded",
949 WorkflowPanelLifecycle::Failed => "failed",
950 WorkflowPanelLifecycle::Cancelled => "cancelled",
951 };
952 let (done, total) = self.done_total();
953 let (failed, cancelled) = self.failure_cancel_counts();
954 json!({
955 "run_id": self.run_id,
956 "status": status,
957 "workflow_goal": self.label,
958 "started_at_ms": self.started_at_ms,
959 "completed_at_ms": self.completed_at_ms,
960 "child_count": total,
961 "done_count": done,
962 "phase_count": self.phase_count(),
963 "failure_count": failed,
964 "cancel_count": cancelled,
965 "error": self.error,
966 "result_summary": self.result_summary,
967 "source_path": self.source_path.as_ref().map(|p| p.display().to_string()),
968 "spillover_path": self.spillover_path.as_ref().map(|p| p.display().to_string()),
969 "token_budget": self.budget_total,
970 "budget_spent": self.budget_spent,
971 "budget_remaining": self.budget_remaining,
972 "dispatch_failure_count": self.dispatch_failure_count,
973 "dispatch_failures": self.dispatch_failures.iter().map(|failure| {
974 json!({
975 "label": failure.label.as_deref(),
976 "phase": failure.phase.as_deref(),
977 "message": failure.message.as_str(),
978 "at_ms": failure.at_ms,
979 })
980 }).collect::<Vec<_>>(),
981 "gates": self.gates.iter().map(|gate| {
982 json!({
983 "gate_id": gate.gate_id.as_str(),
984 "role": gate.role.as_deref(),
985 "gate": gate.gate.as_deref(),
986 "state": gate.state.as_str(),
987 "blocked_role": gate.blocked_role.as_deref(),
988 "blocked_reason": gate.blocked_reason.as_deref(),
989 })
990 }).collect::<Vec<_>>(),
991 "phases": self.phases.iter().map(workflow_phase_run_json).collect::<Vec<_>>(),
992 })
993 }
994
995 /// Compact one-line history-card summary: lifecycle, settled/total
996 /// children, phases, failures (only when there are any), elapsed (#4122,
997 /// #6503). The free-text goal lives on the expanded body so the fixed
998 /// header summary budget (≈56 cols) never drops counts.
999 #[must_use]
1000 pub fn compact_summary_text(&self, width: usize) -> String {
1001 let raw = format!(
1002 "workflow {life} · {counts}",
1003 life = self.lifecycle.display_label(self.locale),
1004 counts = self.summary_counts_text(),
1005 );
1006 truncate_line_to_width(&raw, width.max(1))
1007 }
1008
1009 /// `N/M done · P phases[ · F failed][ · C cancelled] · elapsed`. The
1010 /// settled count is left out until a child exists.
1011 fn summary_counts_text(&self) -> String {
1012 let (_, total) = self.done_total();
1013 let phases = self.phase_count();
1014 let phase_id = if phases == 1 {
1015 MessageId::WorkflowPhaseCountOne
1016 } else {
1017 MessageId::WorkflowPhaseCountMany
1018 };
1019 let settled = if total > 0 {
1020 format!("{} · ", self.settled_text())
1021 } else {
1022 String::new()
1023 };
1024 format!(
1025 "{settled}{phases}{problems} · {elapsed}",
1026 phases = count_text(self.locale, phase_id, phases),
1027 problems = self.problem_counts_suffix(),
1028 elapsed = self.elapsed_label(),
1029 )
1030 }
1031
1032 /// `N/M done`, localized.
1033 fn settled_text(&self) -> String {
1034 let (done, total) = self.done_total();
1035 tr(self.locale, MessageId::WorkflowSettledOfTotal)
1036 .replace("{done}", &done.to_string())
1037 .replace("{total}", &total.to_string())
1038 }
1039
1040 /// ` · N failed · N cancelled`, each only when non-zero (#6503): a
1041 /// healthy run does not spend header columns announcing `0 fail`.
1042 fn problem_counts_suffix(&self) -> String {
1043 let (failed, cancelled) = self.failure_cancel_counts();
1044 let mut out = String::new();
1045 if failed > 0 {
1046 out.push_str(" · ");
1047 out.push_str(&count_text(
1048 self.locale,
1049 MessageId::WorkflowCountFailed,
1050 failed,
1051 ));
1052 }
1053 if cancelled > 0 {
1054 out.push_str(" · ");
1055 out.push_str(&count_text(
1056 self.locale,
1057 MessageId::WorkflowCountCancelled,
1058 cancelled,
1059 ));
1060 }
1061 out
1062 }
1063
1064 /// Elapsed label shared with direct sub-agent cards.
1065 #[must_use]
1066 pub fn elapsed_label(&self) -> String {
1067 // Guard against epoch-zero starts (bare status payloads without
1068 // timestamps) which would otherwise render multi-year elapsed times.
1069 if self.started_at_ms == 0 {
1070 if let Some(completed) = self.completed_at_ms {
1071 return crate::elapsed::format_elapsed_ms(completed);
1072 }
1073 return "0s".to_string();
1074 }
1075 let end = self.completed_at_ms.unwrap_or_else(now_ms);
1076 crate::elapsed::format_elapsed_ms(end.saturating_sub(self.started_at_ms))
1077 }
1078
1079 /// Expanded history-card body lines (phase/child summaries, links,
1080 /// result, failures). Empty when the card should stay compact.
1081 #[must_use]
1082 pub fn history_expanded_lines(
1083 &self,
1084 width: u16,
1085 extras: &WorkflowHistoryExtras,
1086 ) -> Vec<Line<'static>> {
1087 let content_width = usize::from(width).max(1);
1088 let mut lines = Vec::new();
1089
1090 if !self.label.trim().is_empty() {
1091 lines.push(Line::from(Span::styled(
1092 truncate_line_to_width(
1093 &format!("goal: {}", short_label(self.label.trim(), 160)),
1094 content_width,
1095 ),
1096 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
1097 )));
1098 }
1099
1100 // Phase summary strip (same chips as the panel body).
1101 if !self.phases.is_empty() {
1102 let mut chips = Vec::new();
1103 for (idx, phase) in self.phases.iter().take(MAX_PHASE_SUMMARY).enumerate() {
1104 let marker = crate::tui::glyphs::selection_marker(idx == self.selected_phase);
1105 chips.push(format!(
1106 "{marker}{title} ({counts})",
1107 title = short_label(&phase.title, 24),
1108 counts = phase.counts_text(self.locale, ", "),
1109 ));
1110 }
1111 if self.phases.len() > MAX_PHASE_SUMMARY {
1112 chips.push(format!("+{}", self.phases.len() - MAX_PHASE_SUMMARY));
1113 }
1114 lines.push(Line::from(Span::styled(
1115 truncate_line_to_width(&format!("phases: {}", chips.join(" ")), content_width),
1116 Style::default().fg(palette::TEXT_MUTED),
1117 )));
1118 }
1119
1120 if !self.gates.is_empty() {
1121 lines.push(Line::from(Span::styled(
1122 truncate_line_to_width(&format!("gates: {}", self.gates_summary()), content_width),
1123 Style::default().fg(palette::TEXT_MUTED),
1124 )));
1125 }
1126
1127 // Child summary across all phases.
1128 let children: Vec<String> = self
1129 .phases
1130 .iter()
1131 .flat_map(|p| p.rows.iter())
1132 .take(8)
1133 .map(|row| {
1134 format!(
1135 "{mark} {label} ({status})",
1136 mark = role_mark(row.profile.as_deref()),
1137 label = short_label(&row.label, 16),
1138 status = row.status.display_label(self.locale)
1139 )
1140 })
1141 .collect();
1142 if !children.is_empty() {
1143 let more = self
1144 .phases
1145 .iter()
1146 .map(|p| p.rows.len())
1147 .sum::<usize>()
1148 .saturating_sub(children.len());
1149 let mut body = children.join(" · ");
1150 if more > 0 {
1151 body = format!("{body} · +{more} more");
1152 }
1153 lines.push(Line::from(Span::styled(
1154 truncate_line_to_width(&format!("children: {body}"), content_width),
1155 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
1156 )));
1157 }
1158
1159 // The history variant uses the same real-data lane vocabulary as the
1160 // live panel. Durations are proportional within the run; gates remain
1161 // a separate named line because runtime events do not yet timestamp
1162 // them precisely enough to place them on a synthetic timeline.
1163 let rows = self.phases.iter().flat_map(|phase| phase.rows.iter());
1164 let max_elapsed = rows
1165 .clone()
1166 .map(|row| row_elapsed_ms(row, now_ms()))
1167 .max()
1168 .unwrap_or(0);
1169 for row in rows.take(8) {
1170 lines.push(Line::from(Span::styled(
1171 truncate_line_to_width(
1172 &format!(
1173 "lane {mark} {label:<14} {track} {elapsed} {status}",
1174 mark = role_mark(row.profile.as_deref()),
1175 label = short_label(&row.label, 14),
1176 track = lane_track(row, max_elapsed, 16, now_ms()),
1177 elapsed = crate::elapsed::format_elapsed_ms(row_elapsed_ms(row, now_ms())),
1178 status = row.status.display_label(self.locale),
1179 ),
1180 content_width,
1181 ),
1182 Style::default().fg(row.status.color()),
1183 )));
1184 // #4039: the history card shows the same immutable receipt as the
1185 // live panel, so a finished run stays auditable after the fact.
1186 if self.show_workflow_receipts {
1187 lines.extend(
1188 receipt_line_strings(row, self.locale, content_width, 2)
1189 .into_iter()
1190 .map(|text| {
1191 Line::from(Span::styled(text, Style::default().fg(palette::TEXT_MUTED)))
1192 }),
1193 );
1194 }
1195 }
1196
1197 // Rejected launches are run-level failures rather than child lanes.
1198 // Keep their newest bounded details visible in the completed card.
1199 lines.extend(self.render_dispatch_failure_lines(content_width));
1200
1201 if self.lifecycle.is_terminal() {
1202 let (done, total) = self.done_total();
1203 let (failed, cancelled) = self.failure_cancel_counts();
1204 lines.push(Line::from(Span::styled(
1205 truncate_line_to_width(
1206 &tr(self.locale, MessageId::WorkflowDebrief)
1207 .replace("{done}", &done.to_string())
1208 .replace("{total}", &total.to_string())
1209 .replace("{failed}", &failed.to_string())
1210 .replace("{cancelled}", &cancelled.to_string())
1211 .replace("{elapsed}", &self.elapsed_label()),
1212 content_width,
1213 ),
1214 Style::default().fg(palette::TEXT_MUTED),
1215 )));
1216 }
1217
1218 let result = extras
1219 .result_summary
1220 .as_deref()
1221 .or(self.result_summary.as_deref())
1222 .or(extras.verification_summary.as_deref());
1223 if let Some(result) = result.filter(|s| !s.trim().is_empty()) {
1224 lines.push(Line::from(Span::styled(
1225 truncate_line_to_width(
1226 &format!("result: {}", short_label(result.trim(), 160)),
1227 content_width,
1228 ),
1229 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
1230 )));
1231 }
1232
1233 let source = extras
1234 .source_path
1235 .as_ref()
1236 .or(self.source_path.as_ref())
1237 .map(|p| p.display().to_string());
1238 if let Some(path) = source.filter(|s| !s.is_empty()) {
1239 lines.push(Line::from(Span::styled(
1240 truncate_line_to_width(&format!("source: {path}"), content_width),
1241 Style::default().fg(palette::TEXT_MUTED),
1242 )));
1243 }
1244 let spill = extras
1245 .spillover_path
1246 .as_ref()
1247 .or(self.spillover_path.as_ref())
1248 .map(|p| p.display().to_string());
1249 if let Some(path) = spill.filter(|s| !s.is_empty()) {
1250 lines.push(Line::from(Span::styled(
1251 truncate_line_to_width(&format!("artifact: {path}"), content_width),
1252 Style::default().fg(palette::TEXT_MUTED),
1253 )));
1254 } else if self.lifecycle.is_terminal() {
1255 let details = crate::tui::shell_key_routing::tool_details_chord();
1256 let transcript_hint = tr(self.locale, MessageId::WorkflowTranscriptDetails)
1257 .replace("{details}", details.as_ref());
1258 lines.push(Line::from(Span::styled(
1259 truncate_line_to_width(&transcript_hint, content_width),
1260 Style::default().fg(palette::TEXT_MUTED),
1261 )));
1262 }
1263
1264 if let Some(error) = self.error.as_deref().filter(|s| !s.trim().is_empty()) {
1265 lines.push(Line::from(Span::styled(
1266 truncate_line_to_width(
1267 &format!("error: {}", short_label(error, 160)),
1268 content_width,
1269 ),
1270 Style::default().fg(palette::STATUS_ERROR),
1271 )));
1272 }
1273 for row in self.phases.iter().flat_map(|p| p.rows.iter()) {
1274 if let Some(schema) = row.schema_error.as_deref() {
1275 lines.push(Line::from(Span::styled(
1276 truncate_line_to_width(
1277 &format!(
1278 "schema {}: {}",
1279 short_label(&row.task_id, 12),
1280 short_label(schema, 120)
1281 ),
1282 content_width,
1283 ),
1284 Style::default().fg(palette::STATUS_ERROR),
1285 )));
1286 } else if row.status.is_failure()
1287 && let Some(err) = row.error.as_deref()
1288 {
1289 lines.push(Line::from(Span::styled(
1290 truncate_line_to_width(
1291 &format!(
1292 "fail {}: {}",
1293 short_label(&row.label, 14),
1294 short_label(err, 120)
1295 ),
1296 content_width,
1297 ),
1298 Style::default().fg(palette::STATUS_ERROR),
1299 )));
1300 }
1301 }
1302
1303 lines
1304 }
1305
1306 /// Full history-card lines including a simple self-contained header so
1307 /// unit tests (and direct sub-agent cards) can render without history.rs.
1308 ///
1309 /// Public convergence API for #4122 — also exercised by unit tests and
1310 /// `DelegateCard::as_workflow_history_panel`.
1311 #[must_use]
1312 #[allow(dead_code)] // public API used by direct sub-agent projection + tests
1313 pub fn render_history_card(
1314 &self,
1315 width: u16,
1316 expanded: bool,
1317 extras: &WorkflowHistoryExtras,
1318 ) -> Vec<Line<'static>> {
1319 let content_width = usize::from(width).max(1);
1320 let mut lines = Vec::new();
1321 let glyph = if expanded { '▼' } else { '▶' };
1322 let summary = self.compact_summary_text(content_width.saturating_sub(2));
1323 lines.push(Line::from(Span::styled(
1324 truncate_line_to_width(&format!("{glyph} {summary}"), content_width),
1325 Style::default()
1326 .fg(self.lifecycle.color())
1327 .add_modifier(Modifier::BOLD),
1328 )));
1329 if expanded {
1330 lines.extend(self.history_expanded_lines(width, extras));
1331 }
1332 lines
1333 }
1334
1335 /// Single-agent "mini workflow" view for direct sub-agent cards so they
1336 /// share the same lifecycle/elapsed/result concepts as workflow runs.
1337 #[must_use]
1338 #[allow(dead_code)] // public API used by DelegateCard + tests
1339 pub fn from_direct_subagent(
1340 agent_id: impl Into<String>,
1341 role: impl Into<String>,
1342 lifecycle: WorkflowPanelLifecycle,
1343 started_at_ms: u64,
1344 completed_at_ms: Option<u64>,
1345 summary: Option<String>,
1346 error: Option<String>,
1347 ) -> Self {
1348 let agent_id = agent_id.into();
1349 let role = role.into();
1350 let mut panel = Self::new(agent_id.clone(), role.clone(), started_at_ms);
1351 panel.lifecycle = lifecycle;
1352 panel.completed_at_ms = completed_at_ms;
1353 panel.show_workflow_receipts = false;
1354 panel.result_summary = summary.clone();
1355 panel.error = error.clone();
1356 let status = match lifecycle {
1357 WorkflowPanelLifecycle::Pending => WorkflowRowStatus::Pending,
1358 WorkflowPanelLifecycle::Running => WorkflowRowStatus::Running,
1359 WorkflowPanelLifecycle::Succeeded => WorkflowRowStatus::Succeeded,
1360 WorkflowPanelLifecycle::Degraded => WorkflowRowStatus::Failed,
1361 WorkflowPanelLifecycle::Failed => WorkflowRowStatus::Failed,
1362 WorkflowPanelLifecycle::Cancelled => WorkflowRowStatus::Cancelled,
1363 };
1364 let mut phase = WorkflowPanelPhase::new("Agent");
1365 phase.rows.push(WorkflowPanelRow {
1366 task_id: agent_id,
1367 label: role,
1368 profile: None,
1369 model: None,
1370 strength: None,
1371 worktree: false,
1372 workspace: None,
1373 status,
1374 started_at_ms,
1375 completed_at_ms,
1376 error,
1377 schema_error: None,
1378 // A direct sub-agent card projects a single agent, not a Workflow
1379 // task, so it carries no Workflow launch/usage receipt (#4039).
1380 route: WorkflowRowRoute::default(),
1381 usage: completed_at_ms.map(|_| WorkflowRowUsage::default()),
1382 });
1383 panel.phases.push(phase);
1384 panel
1385 }
1386
1387 /// Apply a stream of events. `RunStarted` replaces any prior completed run.
1388 pub fn apply_event(&mut self, event: WorkflowPanelEvent) {
1389 match event {
1390 WorkflowPanelEvent::RunStarted {
1391 run_id,
1392 workflow_id,
1393 workflow_goal,
1394 source_path,
1395 token_budget,
1396 at_ms,
1397 } => {
1398 // A repeated or replayed start of this same run never resets
1399 // it: rebuilding would erase settled rows and a terminal
1400 // outcome (U05-01). It names the run (its goal is fixed for
1401 // the run) and fills only what the panel still lacks, such as
1402 // the start time of a placeholder opened by a late event.
1403 if run_id == self.run_id {
1404 if let Some(label) = workflow_goal.or(workflow_id) {
1405 self.label = label;
1406 }
1407 if self.started_at_ms == 0 {
1408 self.started_at_ms = at_ms;
1409 }
1410 if self.budget_total.is_none() {
1411 self.budget_total = token_budget;
1412 self.budget_remaining = token_budget;
1413 }
1414 if self.source_path.is_none() {
1415 self.source_path = source_path;
1416 }
1417 return;
1418 }
1419 // New run replaces preserved completed state.
1420 let locale = self.locale;
1421 *self = Self::new(
1422 run_id,
1423 workflow_goal
1424 .or(workflow_id)
1425 .unwrap_or_else(|| "workflow".to_string()),
1426 at_ms,
1427 );
1428 self.locale = locale;
1429 self.budget_total = token_budget;
1430 self.budget_remaining = token_budget;
1431 self.source_path = source_path;
1432 }
1433 WorkflowPanelEvent::RunCompleted {
1434 status,
1435 error,
1436 at_ms,
1437 } => {
1438 // A cancel is final. Under backpressure the VM's own
1439 // `run_completed` can land after `run_cancelled`; it must not
1440 // repaint a stopped run as finished or drop the cancel reason.
1441 if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
1442 return;
1443 }
1444 // A completion receipt that still says running/pending is
1445 // contradictory, not a success.
1446 self.lifecycle = if status.is_running() {
1447 WorkflowPanelLifecycle::Failed
1448 } else {
1449 status
1450 };
1451 self.error = error;
1452 self.completed_at_ms = Some(at_ms);
1453 }
1454 WorkflowPanelEvent::RunCancelled { reason, at_ms } => {
1455 self.finalize_running_rows(WorkflowRowStatus::Cancelled, at_ms);
1456 self.lifecycle = WorkflowPanelLifecycle::Cancelled;
1457 self.error = Some(reason);
1458 self.completed_at_ms = Some(at_ms);
1459 }
1460 WorkflowPanelEvent::PhaseStarted { title, at_ms: _ } => {
1461 if self.phases.last().is_some_and(|phase| phase.title == title) {
1462 return;
1463 }
1464 self.phases.push(WorkflowPanelPhase::new(title));
1465 self.selected_phase = self.phases.len().saturating_sub(1);
1466 }
1467 WorkflowPanelEvent::TaskStarted {
1468 task_id,
1469 label,
1470 profile,
1471 model,
1472 strength,
1473 resolved_model,
1474 worktree,
1475 workspace,
1476 route,
1477 at_ms,
1478 } => {
1479 // A settled run is final. A child launch that raced the
1480 // cancel (or any late same-run start) is recorded, but it
1481 // never reopens the run or resets a row that already settled.
1482 let settled = self.lifecycle.is_terminal();
1483 if settled && self.find_row_mut(&task_id).is_some() {
1484 return;
1485 }
1486 if self.phases.is_empty() {
1487 self.phases.push(WorkflowPanelPhase::new("Work"));
1488 self.selected_phase = 0;
1489 }
1490 // Tasks join the runtime's newest phase. `selected_phase` is
1491 // the render cursor and never routes an arriving task.
1492 let phase_idx = self.phases.len().saturating_sub(1);
1493 let display_model = resolved_model.or(model);
1494 let mut row = WorkflowPanelRow {
1495 task_id: task_id.clone(),
1496 label: label
1497 .filter(|s| !s.trim().is_empty())
1498 .unwrap_or_else(|| task_id.clone()),
1499 profile,
1500 model: display_model,
1501 strength,
1502 worktree,
1503 workspace,
1504 status: WorkflowRowStatus::Running,
1505 started_at_ms: at_ms,
1506 completed_at_ms: None,
1507 error: None,
1508 schema_error: None,
1509 route: *route,
1510 usage: None,
1511 };
1512 if self.lifecycle == WorkflowPanelLifecycle::Cancelled {
1513 // Same finalization the cancel applied to running rows.
1514 row.status = WorkflowRowStatus::Cancelled;
1515 row.completed_at_ms = Some(at_ms);
1516 row.usage = Some(WorkflowRowUsage::default());
1517 }
1518 if let Some(existing) = self.find_row_mut(&task_id) {
1519 *existing = row;
1520 } else if let Some(phase) = self.phases.get_mut(phase_idx) {
1521 phase.rows.push(row);
1522 }
1523 if !settled {
1524 self.lifecycle = WorkflowPanelLifecycle::Running;
1525 }
1526 }
1527 WorkflowPanelEvent::TaskCompleted {
1528 task_id,
1529 status,
1530 usage,
1531 reason,
1532 at_ms,
1533 } => {
1534 if let Some(row) = self.find_row_mut(&task_id) {
1535 row.status = status;
1536 row.completed_at_ms = Some(at_ms);
1537 if (status.is_failure() || status.is_cancel())
1538 && let Some(reason) = reason.filter(|text| !text.trim().is_empty())
1539 {
1540 row.error = Some(reason);
1541 }
1542 // A completed row always carries a usage receipt, even when
1543 // every counter in it is unknown (#4039).
1544 row.usage = Some(usage.unwrap_or_default());
1545 }
1546 }
1547 WorkflowPanelEvent::GateUpdated {
1548 gate_id,
1549 role,
1550 gate,
1551 state,
1552 blocked_role,
1553 blocked_reason,
1554 at_ms: _,
1555 } => {
1556 self.upsert_gate(WorkflowPanelGateLine {
1557 gate_id,
1558 role,
1559 gate,
1560 state,
1561 blocked_role,
1562 blocked_reason,
1563 });
1564 }
1565 WorkflowPanelEvent::TaskSchemaValidationFailed {
1566 task_id,
1567 message,
1568 at_ms,
1569 } => {
1570 if let Some(row) = self.find_row_mut(&task_id) {
1571 row.status = WorkflowRowStatus::SchemaFailed;
1572 row.schema_error = Some(message);
1573 row.completed_at_ms = Some(at_ms);
1574 } else {
1575 // Schema can fire before/without a started task.
1576 if self.phases.is_empty() {
1577 self.phases.push(WorkflowPanelPhase::new("Work"));
1578 }
1579 let phase_idx = self.phases.len().saturating_sub(1);
1580 if let Some(phase) = self.phases.get_mut(phase_idx) {
1581 phase.rows.push(WorkflowPanelRow {
1582 task_id,
1583 label: "schema".to_string(),
1584 profile: None,
1585 model: None,
1586 strength: None,
1587 worktree: false,
1588 workspace: None,
1589 status: WorkflowRowStatus::SchemaFailed,
1590 started_at_ms: at_ms,
1591 completed_at_ms: Some(at_ms),
1592 error: None,
1593 schema_error: Some(message),
1594 // Schema failure without a task_started: nothing was
1595 // received about the route, so nothing is claimed.
1596 route: WorkflowRowRoute::default(),
1597 usage: Some(WorkflowRowUsage::default()),
1598 });
1599 }
1600 }
1601 }
1602 WorkflowPanelEvent::TaskDispatchFailed {
1603 label,
1604 phase,
1605 message,
1606 at_ms,
1607 } => {
1608 self.record_dispatch_failure(label, phase, message, at_ms);
1609 // A failed launch can be only one slot in a parallel phase;
1610 // keep the run live so surviving siblings can still finish.
1611 if self.lifecycle.is_running() {
1612 self.lifecycle = WorkflowPanelLifecycle::Running;
1613 }
1614 }
1615 WorkflowPanelEvent::BudgetUpdated {
1616 total,
1617 spent,
1618 remaining,
1619 at_ms: _,
1620 } => {
1621 if total.is_some() {
1622 self.budget_total = total;
1623 }
1624 self.budget_spent = spent;
1625 self.budget_remaining = remaining;
1626 }
1627 }
1628 }
1629
1630 /// Apply one event only when its explicit route identity belongs to this
1631 /// panel. A strictly newer `run_started` is the sole event allowed to
1632 /// select a different run; legacy direct callers without an id remain
1633 /// accepted.
1634 pub fn apply_json_event(&mut self, value: &Value) -> bool {
1635 let event_type = value.get("type").and_then(Value::as_str);
1636 let event_run_id = value
1637 .get("run_id")
1638 .or_else(|| value.get("workflow_run_id"))
1639 .and_then(Value::as_str)
1640 .filter(|run_id| !run_id.trim().is_empty());
1641 if event_type == Some("run_started")
1642 && event_run_id.is_some_and(|run_id| run_id != self.run_id)
1643 && value
1644 .get("at_ms")
1645 .and_then(Value::as_u64)
1646 .is_none_or(|at_ms| at_ms <= self.started_at_ms)
1647 {
1648 return false;
1649 }
1650 if event_type != Some("run_started")
1651 && event_run_id.is_some_and(|run_id| run_id != self.run_id)
1652 {
1653 return false;
1654 }
1655 if let Some(event) = WorkflowPanelEvent::from_json_value(value) {
1656 self.apply_event(event);
1657 return true;
1658 }
1659 false
1660 }
1661
1662 pub fn apply_json_events(&mut self, values: &[Value]) {
1663 for value in values {
1664 self.apply_json_event(value);
1665 }
1666 }
1667
1668 /// Merge the authoritative structured failure ledger carried by a run
1669 /// result after its retained event tail has been applied. The tail can
1670 /// replay events already seen live; the exact top-level count and newest
1671 /// bounded ledger therefore replace, rather than add to, panel state.
1672 pub(crate) fn merge_dispatch_failures_from_run_json(&mut self, value: &Value) {
1673 let fallback_at_ms = value
1674 .get("started_at_ms")
1675 .and_then(Value::as_u64)
1676 .unwrap_or(self.started_at_ms);
1677 let ledger = value
1678 .get("dispatch_failures")
1679 .and_then(Value::as_array)
1680 .map(|failures| {
1681 let start = failures
1682 .len()
1683 .saturating_sub(MAX_DISPATCH_FAILURES_RETAINED);
1684 failures[start..]
1685 .iter()
1686 .map(|failure| {
1687 WorkflowPanelDispatchFailure::bounded(
1688 opt_str(failure, "label"),
1689 opt_str(failure, "phase"),
1690 opt_str(failure, "message").unwrap_or_default(),
1691 failure
1692 .get("at_ms")
1693 .and_then(Value::as_u64)
1694 .unwrap_or(fallback_at_ms),
1695 )
1696 })
1697 .collect::<Vec<_>>()
1698 });
1699 let declared_count = value
1700 .get("dispatch_failure_count")
1701 .and_then(Value::as_u64)
1702 .map(|count| usize::try_from(count).unwrap_or(usize::MAX));
1703
1704 if let Some(ledger) = ledger {
1705 let returned = ledger.len();
1706 self.dispatch_failures = ledger;
1707 self.dispatch_failure_count = declared_count
1708 .map(|count| count.max(returned))
1709 .unwrap_or_else(|| self.dispatch_failure_count.max(returned));
1710 } else if let Some(count) = declared_count {
1711 self.dispatch_failure_count = count;
1712 }
1713 }
1714
1715 /// Tokens the run has used so far: the live meter the engine streams
1716 /// (`budget_updated.spent`) or, when larger, the finished agents'
1717 /// receipts. `None` until something is reported — never a made-up zero.
1718 #[must_use]
1719 pub fn tokens_so_far(&self) -> Option<u64> {
1720 let receipts: u64 = self
1721 .phases
1722 .iter()
1723 .flat_map(|phase| phase.rows.iter())
1724 .filter_map(|row| row.usage.as_ref().and_then(WorkflowRowUsage::token_total))
1725 .sum();
1726 let tokens = self.budget_spent.max(receipts);
1727 (tokens > 0).then_some(tokens)
1728 }
1729
1730 /// The transcript's finish line for a settled run: the state word, the
1731 /// facts (`name · 1/3 done · 2 failed · 2m 14s · ↓1.2M`), and the result
1732 /// or the one reason it fell short. The name is the goal's first sentence
1733 /// and the reason is a first sentence too; the full goal, every agent's
1734 /// error and the run's own error stay on the expanded card. `None` while
1735 /// the run is live.
1736 #[must_use]
1737 pub fn finish_line(&self) -> Option<(String, String, Option<String>)> {
1738 let state = match self.lifecycle {
1739 WorkflowPanelLifecycle::Pending | WorkflowPanelLifecycle::Running => return None,
1740 WorkflowPanelLifecycle::Succeeded => MessageId::WorkflowLineFinished,
1741 WorkflowPanelLifecycle::Degraded => MessageId::WorkflowLineFinishedWithGaps,
1742 WorkflowPanelLifecycle::Failed => MessageId::WorkflowLineFailed,
1743 WorkflowPanelLifecycle::Cancelled => MessageId::WorkflowLineStopped,
1744 };
1745 let (_, _, _, total) = self.row_outcomes();
1746 let mut rest = Vec::new();
1747 if total > 0 || self.dispatch_failure_count > 0 {
1748 rest.push(self.outcome_counts_text());
1749 }
1750 rest.push(self.elapsed_label());
1751 if let Some(tokens) = self.tokens_so_far() {
1752 rest.push(format!(
1753 "↓{}",
1754 crate::tui::footer_ui::format_token_count_compact(tokens)
1755 ));
1756 }
1757 // The title gives way, at a word boundary, so the counts and elapsed
1758 // are never the part the card header cuts off.
1759 let rest = rest.join(" · ");
1760 let title_cols = FINISH_FACTS_COLS
1761 .saturating_sub(rest.width() + 3)
1762 .max(MIN_FINISH_TITLE_COLS);
1763 let facts = [
1764 crate::tui::ui_text::semantic_truncate(&self.short_title(), title_cols),
1765 rest,
1766 ];
1767 let detail = match self.lifecycle {
1768 WorkflowPanelLifecycle::Succeeded | WorkflowPanelLifecycle::Degraded => self
1769 .result_summary
1770 .clone()
1771 .map(|text| text.split_whitespace().collect::<Vec<_>>().join(" "))
1772 .or_else(|| self.outcome_reason())
1773 .or_else(|| self.error.as_deref().map(first_sentence)),
1774 _ => self
1775 .outcome_reason()
1776 .or_else(|| self.result_summary.as_deref().map(first_sentence)),
1777 }
1778 .filter(|text| !text.is_empty());
1779 Some((
1780 tr(self.locale, state).into_owned(),
1781 facts.join(" · "),
1782 detail,
1783 ))
1784 }
1785
1786 #[must_use]
1787 pub fn done_total(&self) -> (usize, usize) {
1788 let mut done = 0usize;
1789 let mut total = 0usize;
1790 for phase in &self.phases {
1791 for row in &phase.rows {
1792 total += 1;
1793 if !row.status.is_running() {
1794 done += 1;
1795 }
1796 }
1797 }
1798 (done, total)
1799 }
1800
1801 #[must_use]
1802 pub fn phase_count(&self) -> usize {
1803 self.phases.len()
1804 }
1805
1806 #[must_use]
1807 pub fn failure_cancel_counts(&self) -> (usize, usize) {
1808 let mut failed = self.dispatch_failure_count;
1809 let mut cancelled = 0usize;
1810 for phase in &self.phases {
1811 for row in &phase.rows {
1812 if row.status.is_failure() {
1813 failed = failed.saturating_add(1);
1814 } else if row.status.is_cancel() {
1815 cancelled = cancelled.saturating_add(1);
1816 }
1817 }
1818 }
1819 (failed, cancelled)
1820 }
1821
1822 /// Rows by outcome: `(succeeded, failed, cancelled, total)`. Rejected
1823 /// dispatches are not rows, so they are not in these counts.
1824 #[must_use]
1825 pub fn row_outcomes(&self) -> (usize, usize, usize, usize) {
1826 self.phases.iter().map(WorkflowPanelPhase::counts).fold(
1827 (0, 0, 0, 0),
1828 |acc, (done, running, failed, cancelled)| {
1829 (
1830 acc.0 + done,
1831 acc.1 + failed,
1832 acc.2 + cancelled,
1833 acc.3 + done + running + failed + cancelled,
1834 )
1835 },
1836 )
1837 }
1838
1839 /// The run's name on one-line surfaces: the goal's first sentence. The
1840 /// full goal stays on the expanded card, so a paragraph-long goal is not
1841 /// the title of every row that names the run.
1842 #[must_use]
1843 pub fn short_title(&self) -> String {
1844 let title = first_sentence(&self.label);
1845 if title.is_empty() {
1846 "workflow".to_string()
1847 } else {
1848 title
1849 }
1850 }
1851
1852 /// `k/n done · m failed · c cancelled`: what finished, and what did not.
1853 /// Only the done count is always there; a failed or cancelled count shows
1854 /// only when it is not zero. Settled-but-failed agents never count as done.
1855 #[must_use]
1856 pub fn outcome_counts_text(&self) -> String {
1857 let (succeeded, _, _, total) = self.row_outcomes();
1858 let (failed, cancelled) = self.failure_cancel_counts();
1859 let mut parts = Vec::new();
1860 if total > 0 {
1861 parts.push(
1862 tr(self.locale, MessageId::WorkflowSettledOfTotal)
1863 .replace("{done}", &succeeded.to_string())
1864 .replace("{total}", &total.to_string()),
1865 );
1866 } else if failed == 0 {
1867 parts.push(tr(self.locale, MessageId::WorkflowNoTasksYet).into_owned());
1868 }
1869 if failed > 0 {
1870 parts.push(count_text(
1871 self.locale,
1872 MessageId::WorkflowCountFailed,
1873 failed,
1874 ));
1875 }
1876 if cancelled > 0 {
1877 parts.push(count_text(
1878 self.locale,
1879 MessageId::WorkflowCountCancelled,
1880 cancelled,
1881 ));
1882 }
1883 parts.join(" · ")
1884 }
1885
1886 /// The one reason worth showing for a run that settled short of success,
1887 /// as its first sentence. When nothing succeeded, a failed agent's own
1888 /// reason ("Authorization failed: …") beats the run's aggregate ("no task
1889 /// produced a result: all 2 task(s) failed …"): it is the cause a person
1890 /// can act on. `None` for a live or fully successful run.
1891 #[must_use]
1892 pub fn outcome_reason(&self) -> Option<String> {
1893 let row_reason = || {
1894 self.phases
1895 .iter()
1896 .flat_map(|phase| phase.rows.iter())
1897 .filter(|row| row.status.is_failure())
1898 .find_map(|row| row.error.as_deref().or(row.schema_error.as_deref()))
1899 .map(str::to_string)
1900 .or_else(|| {
1901 self.dispatch_failures
1902 .first()
1903 .map(|failure| failure.message.clone())
1904 })
1905 };
1906 let run_error = || self.error.clone();
1907 let reason = match self.lifecycle {
1908 WorkflowPanelLifecycle::Pending
1909 | WorkflowPanelLifecycle::Running
1910 | WorkflowPanelLifecycle::Succeeded => return None,
1911 WorkflowPanelLifecycle::Cancelled => run_error(),
1912 WorkflowPanelLifecycle::Degraded => row_reason().or_else(run_error),
1913 WorkflowPanelLifecycle::Failed => {
1914 let (succeeded, _, _, _) = self.row_outcomes();
1915 if succeeded == 0 {
1916 row_reason().or_else(run_error)
1917 } else {
1918 run_error().or_else(row_reason)
1919 }
1920 }
1921 };
1922 reason
1923 .map(|text| first_sentence(strip_error_tag(&text)))
1924 .filter(|text| !text.is_empty())
1925 }
1926
1927 fn record_dispatch_failure(
1928 &mut self,
1929 label: Option<String>,
1930 phase: Option<String>,
1931 message: String,
1932 at_ms: u64,
1933 ) {
1934 let failure = WorkflowPanelDispatchFailure::bounded(label, phase, message, at_ms);
1935 self.dispatch_failure_count = self.dispatch_failure_count.saturating_add(1);
1936 self.dispatch_failures.push(failure);
1937 if self.dispatch_failures.len() > MAX_DISPATCH_FAILURES_RETAINED {
1938 let overflow = self.dispatch_failures.len() - MAX_DISPATCH_FAILURES_RETAINED;
1939 self.dispatch_failures.drain(..overflow);
1940 }
1941 }
1942
1943 fn render_dispatch_failure_lines(&self, width: usize) -> Vec<Line<'static>> {
1944 let shown = self
1945 .dispatch_failures
1946 .len()
1947 .min(MAX_VISIBLE_DISPATCH_FAILURES);
1948 let start = self.dispatch_failures.len().saturating_sub(shown);
1949 let mut lines = Vec::with_capacity(shown.saturating_add(1));
1950 for failure in &self.dispatch_failures[start..] {
1951 let slot = match (failure.label.as_deref(), failure.phase.as_deref()) {
1952 (Some(label), Some(phase)) if label != phase => {
1953 format!("{} [{}]", short_label(label, 28), short_label(phase, 20))
1954 }
1955 (Some(label), _) => short_label(label, 28),
1956 (None, Some(phase)) => short_label(phase, 28),
1957 (None, None) => {
1958 tr(self.locale, MessageId::WorkflowDispatchFallbackTask).into_owned()
1959 }
1960 };
1961 let message = if failure.message.is_empty() {
1962 tr(self.locale, MessageId::SetupStatusFailed).into_owned()
1963 } else {
1964 short_label(&failure.message, 160)
1965 };
1966 let text = tr(self.locale, MessageId::WorkflowDispatchFailureLine)
1967 .replace("{slot}", &slot)
1968 .replace("{message}", &message);
1969 lines.push(Line::from(Span::styled(
1970 truncate_line_to_width(&text, width.max(1)),
1971 Style::default().fg(palette::STATUS_ERROR),
1972 )));
1973 }
1974 let omitted = self.dispatch_failure_count.saturating_sub(shown);
1975 if omitted > 0 {
1976 let text = tr(self.locale, MessageId::WorkflowDispatchFailuresOmitted)
1977 .replace("{count}", &omitted.to_string());
1978 lines.push(Line::from(Span::styled(
1979 truncate_line_to_width(&text, width.max(1)),
1980 Style::default().fg(palette::TEXT_MUTED),
1981 )));
1982 }
1983 lines
1984 }
1985
1986 fn find_row_mut(&mut self, task_id: &str) -> Option<&mut WorkflowPanelRow> {
1987 for phase in &mut self.phases {
1988 if let Some(row) = phase.rows.iter_mut().find(|r| r.task_id == task_id) {
1989 return Some(row);
1990 }
1991 }
1992 None
1993 }
1994
1995 fn gates_summary(&self) -> String {
1996 self.gates
1997 .iter()
1998 .take(6)
1999 .map(|gate| {
2000 let target = gate
2001 .blocked_role
2002 .as_deref()
2003 .or(gate.role.as_deref())
2004 .unwrap_or("-");
2005 if let Some(reason) = gate.blocked_reason.as_deref() {
2006 format!(
2007 "{}:{}->{} ({})",
2008 short_label(&gate.gate_id, 18),
2009 gate.state,
2010 target,
2011 short_label(reason, 40)
2012 )
2013 } else {
2014 format!(
2015 "{}:{}->{}",
2016 short_label(&gate.gate_id, 18),
2017 gate.state,
2018 target
2019 )
2020 }
2021 })
2022 .collect::<Vec<_>>()
2023 .join(" ")
2024 }
2025
2026 fn upsert_gate(&mut self, gate: WorkflowPanelGateLine) {
2027 if let Some(existing) = self
2028 .gates
2029 .iter_mut()
2030 .find(|existing| existing.gate_id == gate.gate_id)
2031 {
2032 *existing = gate;
2033 } else {
2034 self.gates.push(gate);
2035 }
2036 }
2037
2038 fn finalize_running_rows(&mut self, status: WorkflowRowStatus, at_ms: u64) {
2039 for phase in &mut self.phases {
2040 for row in &mut phase.rows {
2041 if row.status.is_running() {
2042 row.status = status;
2043 row.completed_at_ms = Some(at_ms);
2044 // A terminal Workflow row must keep the receipt shape even
2045 // when cancellation arrived before provider telemetry.
2046 // Unknown counters remain unknown; they never disappear or
2047 // become fabricated zeros (#4039).
2048 row.usage.get_or_insert_with(WorkflowRowUsage::default);
2049 }
2050 }
2051 }
2052 }
2053 }
2054
2055 fn workflow_phase_run_json(phase: &WorkflowPanelPhase) -> Value {
2056 json!({
2057 "title": phase.title,
2058 "rows": phase.rows.iter().map(workflow_row_run_json).collect::<Vec<_>>(),
2059 })
2060 }
2061
2062 fn workflow_row_run_json(row: &WorkflowPanelRow) -> Value {
2063 let usage = row.usage.as_ref().map(|usage| {
2064 json!({
2065 "input_tokens": usage.input_tokens,
2066 "output_tokens": usage.output_tokens,
2067 "total_tokens": usage.total_tokens,
2068 "tool_calls": usage.tool_calls,
2069 "duration_ms": usage.duration_ms,
2070 "token_source": usage.token_source.map(WorkflowTokenSource::as_str),
2071 })
2072 });
2073 json!({
2074 "task_id": row.task_id,
2075 "label": row.label,
2076 "profile": row.profile,
2077 "model": row.model,
2078 "strength": row.strength,
2079 "worktree": row.worktree,
2080 "workspace": row.workspace.as_ref().map(|p| p.display().to_string()),
2081 "status": row.status.label(),
2082 "started_at_ms": row.started_at_ms,
2083 "completed_at_ms": row.completed_at_ms,
2084 "error": row.error,
2085 "schema_error": row.schema_error,
2086 "role": row.route.role,
2087 "provider": row.route.provider,
2088 "resolved_model": row.route.model,
2089 "requested_reasoning": row.route.requested_reasoning,
2090 "effective_reasoning": row.route.effective_reasoning,
2091 "route_source": row.route.route_source.map(WorkflowRouteSource::as_str),
2092 "child_route": row.route.child_route,
2093 "usage": usage,
2094 })
2095 }
2096
2097 fn lifecycle_from_status(status: &str) -> WorkflowPanelLifecycle {
2098 match status {
2099 "running" => WorkflowPanelLifecycle::Running,
2100 "completed" | "succeeded" | "success" => WorkflowPanelLifecycle::Succeeded,
2101 "degraded" => WorkflowPanelLifecycle::Degraded,
2102 "failed" | "error" => WorkflowPanelLifecycle::Failed,
2103 "cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
2104 "pending" => WorkflowPanelLifecycle::Pending,
2105 _ => WorkflowPanelLifecycle::Failed,
2106 }
2107 }
2108
2109 /// Drop a leading error-taxonomy tag (`[auth] `, `[rate_limit] `): the
2110 /// sentence after it already says what kind of failure it was, and a one-row
2111 /// surface has no columns to spare. The full error keeps its tag.
2112 fn strip_error_tag(text: &str) -> &str {
2113 let trimmed = text.trim_start();
2114 trimmed
2115 .strip_prefix('[')
2116 .and_then(|rest| rest.split_once("] "))
2117 .filter(|(tag, _)| {
2118 !tag.is_empty()
2119 && tag.len() <= 24
2120 && tag
2121 .chars()
2122 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
2123 })
2124 .map_or(trimmed, |(_, rest)| rest)
2125 }
2126
2127 /// The first sentence of `text`, whitespace-flattened, without its closing
2128 /// full stop. A sentence ends at `.`, `!` or `?` followed by whitespace, or at
2129 /// `;` — so `v0.10.1.` inside a sentence and `a.rs` never split it. Nothing is
2130 /// cut mid-word: the caller truncates, at a word boundary, only what does not
2131 /// fit.
2132 pub(crate) fn first_sentence(text: &str) -> String {
2133 let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
2134 let mut end = flat.len();
2135 let mut chars = flat.char_indices().peekable();
2136 while let Some((index, ch)) = chars.next() {
2137 let next_is_space = chars.peek().is_some_and(|(_, next)| *next == ' ');
2138 if ch == ';' || (matches!(ch, '.' | '!' | '?') && next_is_space) {
2139 end = if ch == '.' || ch == ';' {
2140 index
2141 } else {
2142 index + ch.len_utf8()
2143 };
2144 break;
2145 }
2146 }
2147 flat[..end].trim_end_matches(['.', ' ']).trim().to_string()
2148 }
2149
2150 /// `{count} running`-style labelled count.
2151 fn count_text(locale: Locale, id: MessageId, count: usize) -> String {
2152 tr(locale, id).replace("{count}", &count.to_string())
2153 }
2154
2155 fn localized_field(locale: Locale, id: MessageId, value: &str) -> String {
2156 tr(locale, id).replace("{value}", value)
2157 }
2158
2159 /// Launch route only: role, provider/model, requested→effective reasoning,
2160 /// route source (#4039).
2161 fn route_receipt_parts(row: &WorkflowPanelRow, locale: Locale) -> Vec<String> {
2162 let unknown = tr(locale, MessageId::WorkflowReceiptUnknown).into_owned();
2163 let role = WorkflowRowRoute::field(row.route.role.as_ref(), locale);
2164 let provider = WorkflowRowRoute::field(row.route.provider.as_ref(), locale);
2165 let model = WorkflowRowRoute::field(row.route.model.as_ref(), locale);
2166 let requested = WorkflowRowRoute::field(row.route.requested_reasoning.as_ref(), locale);
2167 let effective = WorkflowRowRoute::field(row.route.effective_reasoning.as_ref(), locale);
2168 let source = row
2169 .route
2170 .route_source
2171 .map(WorkflowRouteSource::as_str)
2172 .unwrap_or(unknown.as_str());
2173 vec![
2174 localized_field(locale, MessageId::WorkflowReceiptRole, &role),
2175 format!("{provider}/{model}"),
2176 localized_field(
2177 locale,
2178 MessageId::WorkflowReceiptReasoning,
2179 &format!("{requested}→{effective}"),
2180 ),
2181 localized_field(locale, MessageId::WorkflowReceiptVia, source),
2182 ]
2183 }
2184
2185 /// Full receipt: launch route plus, for a settled row, what it cost.
2186 fn receipt_parts(row: &WorkflowPanelRow, locale: Locale) -> Vec<String> {
2187 let unknown = tr(locale, MessageId::WorkflowReceiptUnknown).into_owned();
2188 let mut parts = route_receipt_parts(row, locale);
2189 if let Some(usage) = row.usage.as_ref() {
2190 let tokens = usage.token_total().map_or_else(
2191 || unknown.clone(),
2192 |total| format!("{total} ({})", usage.token_source_label(locale)),
2193 );
2194 let tools = usage
2195 .tool_calls
2196 .map_or_else(|| unknown.clone(), |calls| calls.to_string());
2197 let duration = usage
2198 .duration_ms
2199 .map_or_else(|| unknown.clone(), crate::elapsed::format_elapsed_ms);
2200 parts.extend([
2201 localized_field(locale, MessageId::WorkflowReceiptTokens, &tokens),
2202 localized_field(locale, MessageId::WorkflowReceiptTools, &tools),
2203 localized_field(locale, MessageId::WorkflowReceiptDuration, &duration),
2204 ]);
2205 }
2206 parts
2207 }
2208
2209 /// Full English receipt retained for history serialization tests and text
2210 /// exports. Renderers use [`receipt_line_strings`] so narrow terminals wrap
2211 /// fields instead of dropping them.
2212 #[must_use]
2213 #[cfg(test)]
2214 pub fn row_receipt_text(row: &WorkflowPanelRow) -> String {
2215 receipt_parts(row, Locale::En).join(" · ")
2216 }
2217
2218 fn receipt_line_strings(
2219 row: &WorkflowPanelRow,
2220 locale: Locale,
2221 width: usize,
2222 requested_indent: usize,
2223 ) -> Vec<String> {
2224 pack_receipt_lines(receipt_parts(row, locale), width, requested_indent)
2225 }
2226
2227 /// Pack receipt fields into indented lines that fit `width`, wrapping whole
2228 /// fields first and hard-wrapping only a field wider than the line.
2229 fn pack_receipt_lines(parts: Vec<String>, width: usize, requested_indent: usize) -> Vec<String> {
2230 let indent = requested_indent.min(width.saturating_sub(1));
2231 let prefix = " ".repeat(indent);
2232 let available = width.saturating_sub(indent).max(1);
2233 let mut packed = Vec::new();
2234 let mut current = String::new();
2235
2236 for part in parts {
2237 if UnicodeWidthStr::width(part.as_str()) > available {
2238 if !current.is_empty() {
2239 packed.push(std::mem::take(&mut current));
2240 }
2241 packed.extend(hard_wrap_display(&part, available));
2242 continue;
2243 }
2244 let combined_width = if current.is_empty() {
2245 UnicodeWidthStr::width(part.as_str())
2246 } else {
2247 UnicodeWidthStr::width(current.as_str()) + 3 + UnicodeWidthStr::width(part.as_str())
2248 };
2249 if !current.is_empty() && combined_width > available {
2250 packed.push(std::mem::take(&mut current));
2251 }
2252 if current.is_empty() {
2253 current = part;
2254 } else {
2255 current.push_str(" · ");
2256 current.push_str(&part);
2257 }
2258 }
2259 if !current.is_empty() {
2260 packed.push(current);
2261 }
2262 packed
2263 .into_iter()
2264 .map(|line| format!("{prefix}{line}"))
2265 .collect()
2266 }
2267
2268 fn hard_wrap_display(text: &str, width: usize) -> Vec<String> {
2269 let width = width.max(1);
2270 let mut lines = Vec::new();
2271 let mut line = String::new();
2272 let mut used = 0usize;
2273 for ch in text.chars() {
2274 let ch_width = ch.width().unwrap_or(0);
2275 if !line.is_empty() && used + ch_width > width {
2276 lines.push(std::mem::take(&mut line));
2277 used = 0;
2278 }
2279 line.push(ch);
2280 used += ch_width;
2281 }
2282 if !line.is_empty() {
2283 lines.push(line);
2284 }
2285 if lines.is_empty() {
2286 lines.push(String::new());
2287 }
2288 lines
2289 }
2290
2291 /// Read a terminal usage receipt out of a `task_completed` payload (#4039).
2292 ///
2293 /// Absent counters stay `None`. An object that carries no counter at all still
2294 /// produces a receipt so the row can say `unknown` in every column instead of
2295 /// silently omitting the line.
2296 fn usage_from_json(value: &Value) -> Option<WorkflowRowUsage> {
2297 let object = value.as_object()?;
2298 let number = |key: &str| object.get(key).and_then(Value::as_u64);
2299 Some(WorkflowRowUsage {
2300 input_tokens: number("input_tokens"),
2301 output_tokens: number("output_tokens"),
2302 total_tokens: number("total_tokens"),
2303 tool_calls: number("tool_calls").and_then(|calls| u32::try_from(calls).ok()),
2304 duration_ms: number("duration_ms"),
2305 token_source: opt_str(value, "token_source")
2306 .as_deref()
2307 .and_then(WorkflowTokenSource::parse),
2308 })
2309 }
2310
2311 fn opt_str(value: &Value, key: &str) -> Option<String> {
2312 value
2313 .get(key)
2314 .and_then(Value::as_str)
2315 .map(str::trim)
2316 .filter(|s| !s.is_empty())
2317 .map(str::to_string)
2318 }
2319
2320 fn short_label(text: &str, max: usize) -> String {
2321 let trimmed = text.trim();
2322 if trimmed.width() <= max {
2323 return trimmed.to_string();
2324 }
2325 truncate_line_to_width(trimmed, max)
2326 }
2327
2328 /// Terminal-safe role grammar from the underwater design contract. Labels
2329 /// remain authoritative; the marks make siblings scan as the same work kind.
2330 fn role_mark(profile: Option<&str>) -> &'static str {
2331 let role = profile.unwrap_or_default().trim().to_ascii_lowercase();
2332 if role.contains("operator") {
2333 "@"
2334 } else if role.contains("manager") || role.contains("lead") || role.contains("coordinator") {
2335 "/\\"
2336 } else if role.contains("scout") || role.contains("research") || role.contains("explor") {
2337 "<>"
2338 } else if role.contains("build") || role.contains("implement") || role.contains("engineer") {
2339 "[]"
2340 } else if role.contains("verif") || role.contains("test") || role.contains("qa") {
2341 "()"
2342 } else if role.contains("review") || role.contains("critic") {
2343 "**"
2344 } else {
2345 "--"
2346 }
2347 }
2348
2349 fn row_elapsed_ms(row: &WorkflowPanelRow, now_ms: u64) -> u64 {
2350 row.completed_at_ms
2351 .unwrap_or(now_ms)
2352 .saturating_sub(row.started_at_ms)
2353 }
2354
2355 fn lane_track(row: &WorkflowPanelRow, max_elapsed_ms: u64, width: usize, now_ms: u64) -> String {
2356 let width = width.max(4);
2357 let elapsed = row_elapsed_ms(row, now_ms);
2358 let filled = if max_elapsed_ms == 0 {
2359 1
2360 } else {
2361 ((elapsed as u128 * width as u128) / max_elapsed_ms as u128).clamp(1, width as u128)
2362 as usize
2363 };
2364 let end = match row.status {
2365 WorkflowRowStatus::Succeeded => "OK",
2366 WorkflowRowStatus::Failed | WorkflowRowStatus::SchemaFailed => "!!",
2367 WorkflowRowStatus::Cancelled => "XX",
2368 WorkflowRowStatus::Waiting => "? ",
2369 WorkflowRowStatus::Pending => ". ",
2370 WorkflowRowStatus::Running => "> ",
2371 };
2372 let body_width = width.saturating_sub(2);
2373 let active = filled.saturating_sub(2).min(body_width);
2374 format!(
2375 "{}{}{}",
2376 "=".repeat(active),
2377 end,
2378 "-".repeat(body_width.saturating_sub(active))
2379 )
2380 }
2381
2382 fn now_ms() -> u64 {
2383 SystemTime::now()
2384 .duration_since(UNIX_EPOCH)
2385 .map(|d| d.as_millis() as u64)
2386 .unwrap_or(0)
2387 }
2388
2389 /// The run's result as one readable line — the same wording the engine puts
2390 /// on `run_completed.result_preview`, so the transcript and the parent receipt
2391 /// never describe one result two ways.
2392 fn summarize_result_value(value: &Value) -> Option<String> {
2393 crate::tools::workflow::workflow_result_preview(value)
2394 }
2395
2396 #[cfg(test)]
2397 mod tests {
2398 use super::*;
2399 use serde_json::json;
2400
2401 /// Exactly the flattened `task_started` payload the Workflow runtime emits
2402 /// (`WorkflowTaskStartedEvent` + `run_id`), so the projection is tested on
2403 /// the production wire shape rather than a hand-shaped struct.
2404 fn task_started_json(task_id: &str, provider: &str, model: &str) -> Value {
2405 json!({
2406 "type": "task_started",
2407 "at_ms": 1_200,
2408 "run_id": "workflow_abc",
2409 "task_id": task_id,
2410 "label": task_id,
2411 "role": "implementer",
2412 "profile": "impl-1",
2413 "model": "flash",
2414 "strength": "same",
2415 "thinking": "high",
2416 "requested_reasoning": "high",
2417 "effective_reasoning": "max",
2418 "resolved_role": "verifier",
2419 "resolved_profile": "verify-1",
2420 "resolved_provider": provider,
2421 "resolved_model": model,
2422 "route_source": "agent_profile.model",
2423 "worktree": false,
2424 "depth": 1,
2425 "workflow_run_id": "workflow_abc",
2426 "workflow_task_label": task_id,
2427 })
2428 }
2429
2430 /// Detail view: the history card's expanded body, which carries every
2431 /// row's full receipt (#4039) now that live rows are one line (#6503).
2432 fn detail(panel: &WorkflowPanel, width: u16) -> String {
2433 panel
2434 .history_expanded_lines(width, &WorkflowHistoryExtras::default())
2435 .iter()
2436 .map(|line| {
2437 line.spans
2438 .iter()
2439 .map(|span| span.content.as_ref())
2440 .collect::<String>()
2441 })
2442 .collect::<Vec<_>>()
2443 .join("\n")
2444 }
2445
2446 /// #4039: a row's receipt states the exact role, provider, model,
2447 /// requested → effective reasoning, and route source the runtime reported
2448 /// — and keeps stating them after the session routes somewhere else.
2449 #[test]
2450 fn row_route_receipt_is_exact_and_survives_a_later_model_switch() {
2451 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2452 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2453 let before = detail(&panel, 200);
2454 assert!(before.contains("role verifier"), "{before}");
2455 assert!(before.contains("deepseek/deepseek-v4-flash"), "{before}");
2456 assert!(before.contains("reasoning high→max"), "{before}");
2457 assert!(before.contains("via agent_profile.model"), "{before}");
2458 // A running row claims no totals at all.
2459 assert!(!before.contains("tokens"), "{before}");
2460
2461 // The session now routes elsewhere: a later task lands on another
2462 // provider/model. The already-launched row must not follow it.
2463 panel.apply_json_event(&task_started_json("t2", "moonshot", "kimi-k3"));
2464 let after = detail(&panel, 200);
2465 assert!(after.contains("deepseek/deepseek-v4-flash"), "{after}");
2466 assert!(after.contains("moonshot/kimi-k3"), "{after}");
2467 let t1 = panel
2468 .phases
2469 .iter()
2470 .flat_map(|phase| phase.rows.iter())
2471 .find(|row| row.task_id == "t1")
2472 .expect("t1 row");
2473 assert_eq!(t1.route.provider.as_deref(), Some("deepseek"));
2474 assert_eq!(t1.route.model.as_deref(), Some("deepseek-v4-flash"));
2475 assert_eq!(t1.route.requested_reasoning.as_deref(), Some("high"));
2476 assert_eq!(t1.route.effective_reasoning.as_deref(), Some("max"));
2477 assert_eq!(
2478 t1.route.route_source,
2479 Some(WorkflowRouteSource::AgentProfileModel)
2480 );
2481 }
2482
2483 /// #4039: completed rows show provider-reported totals with their
2484 /// provenance, and unreported telemetry stays `unknown` — never `0`.
2485 #[test]
2486 fn completed_row_usage_is_reported_or_unknown_but_never_a_fabricated_zero() {
2487 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2488 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2489 panel.apply_json_event(&task_started_json("t2", "deepseek", "deepseek-v4-flash"));
2490 panel.apply_json_event(&json!({
2491 "type": "task_completed",
2492 "at_ms": 3_200,
2493 "task_id": "t1",
2494 "status": "succeeded",
2495 "usage": {
2496 "input_tokens": 128,
2497 "output_tokens": 32,
2498 "total_tokens": 160,
2499 "tool_calls": 3,
2500 "duration_ms": 2_000,
2501 "token_source": "provider_reported",
2502 },
2503 }));
2504 // A provider that reported nothing: the runtime omits `usage`.
2505 panel.apply_json_event(&json!({
2506 "type": "task_completed",
2507 "at_ms": 3_400,
2508 "task_id": "t2",
2509 "status": "succeeded",
2510 }));
2511
2512 let text = detail(&panel, 200);
2513 assert!(text.contains("tokens 160 (provider-reported)"), "{text}");
2514 assert!(text.contains("tools 3"), "{text}");
2515 assert!(text.contains("tokens unknown · tools unknown"), "{text}");
2516 let t2 = panel
2517 .phases
2518 .iter()
2519 .flat_map(|phase| phase.rows.iter())
2520 .find(|row| row.task_id == "t2")
2521 .expect("t2 row");
2522 let usage = t2.usage.as_ref().expect("completed rows carry a receipt");
2523 assert_eq!(usage.total_tokens, None);
2524 assert_eq!(usage.tool_calls, None);
2525 }
2526
2527 /// #4039: the projection is built once from the events already applied —
2528 /// the history round trip must carry the receipts rather than force a
2529 /// re-scan of the durable journal.
2530 #[test]
2531 fn row_receipts_survive_the_history_round_trip() {
2532 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2533 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2534 panel.apply_json_event(&json!({
2535 "type": "task_completed",
2536 "at_ms": 3_200,
2537 "task_id": "t1",
2538 "status": "succeeded",
2539 "usage": {
2540 "total_tokens": 160,
2541 "tool_calls": 3,
2542 "duration_ms": 2_000,
2543 "token_source": "provider_reported",
2544 },
2545 }));
2546 let original = panel
2547 .phases
2548 .iter()
2549 .flat_map(|phase| phase.rows.iter())
2550 .map(row_receipt_text)
2551 .collect::<Vec<_>>();
2552
2553 let rehydrated = WorkflowPanel::from_run_json(&panel.to_run_json()).expect("rehydrate");
2554 let round_tripped = rehydrated
2555 .phases
2556 .iter()
2557 .flat_map(|phase| phase.rows.iter())
2558 .map(row_receipt_text)
2559 .collect::<Vec<_>>();
2560 assert_eq!(original, round_tripped);
2561 }
2562
2563 /// #4039 compatibility: journals written before the receipt fields existed
2564 /// still project, and every missing field reads `unknown`.
2565 #[test]
2566 fn legacy_task_events_project_as_unknown_not_as_defaults() {
2567 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2568 panel.apply_json_event(&json!({
2569 "type": "task_started",
2570 "at_ms": 1_200,
2571 "task_id": "t1",
2572 "label": "legacy",
2573 "worktree": false,
2574 }));
2575 panel.apply_json_event(&json!({
2576 "type": "task_completed",
2577 "at_ms": 1_900,
2578 "task_id": "t1",
2579 "status": "succeeded",
2580 }));
2581 let text = detail(&panel, 200);
2582 let unknown_route = "role unknown · unknown/unknown · reasoning unknown→unknown";
2583 assert!(text.contains(unknown_route), "{text}");
2584 assert!(text.contains("via unknown"), "{text}");
2585 assert!(
2586 text.contains("tokens unknown · tools unknown · duration unknown"),
2587 "{text}"
2588 );
2589 }
2590
2591 #[test]
2592 fn requested_model_and_foreign_provenance_never_become_effective_receipts() {
2593 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2594 panel.apply_json_event(&json!({
2595 "type": "task_started",
2596 "at_ms": 1_200,
2597 "task_id": "legacy",
2598 "model": "requested-only",
2599 "thinking": "high",
2600 "route_source": "foreign.source",
2601 "worktree": false,
2602 }));
2603 let row = panel
2604 .phases
2605 .iter()
2606 .flat_map(|phase| phase.rows.iter())
2607 .find(|row| row.task_id == "legacy")
2608 .expect("legacy row");
2609 assert_eq!(row.model.as_deref(), Some("requested-only"));
2610 assert_eq!(row.route.model, None);
2611 assert_eq!(row.route.route_source, None);
2612 let receipt = row_receipt_text(row);
2613 assert!(receipt.contains("unknown/unknown"), "{receipt}");
2614 assert!(receipt.contains("reasoning high→unknown"), "{receipt}");
2615 assert!(receipt.contains("via unknown"), "{receipt}");
2616 }
2617
2618 #[test]
2619 fn receipt_provenance_is_closed_and_a_reported_zero_stays_zero() {
2620 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2621 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2622 panel.apply_json_event(&json!({
2623 "type": "task_completed",
2624 "at_ms": 1_300,
2625 "task_id": "t1",
2626 "status": "succeeded",
2627 "usage": {
2628 "input_tokens": 0,
2629 "output_tokens": 0,
2630 "total_tokens": 0,
2631 "tool_calls": 0,
2632 "duration_ms": 0,
2633 "token_source": "foreign-source",
2634 },
2635 }));
2636 let text = detail(&panel, 200);
2637 assert!(text.contains("tokens 0 (unknown)"), "{text}");
2638 assert!(!text.contains("foreign-source"), "{text}");
2639 }
2640
2641 #[test]
2642 fn required_receipt_fields_survive_release_terminal_sizes() {
2643 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2644 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2645 panel.apply_json_event(&json!({
2646 "type": "task_completed",
2647 "at_ms": 3_200,
2648 "task_id": "t1",
2649 "status": "succeeded",
2650 "usage": {
2651 "total_tokens": 160,
2652 "tool_calls": 3,
2653 "duration_ms": 2_000,
2654 "token_source": "provider_reported",
2655 },
2656 }));
2657
2658 for width in [40_u16, 60, 80] {
2659 let lines = panel.history_expanded_lines(width, &WorkflowHistoryExtras::default());
2660 let text = lines
2661 .iter()
2662 .map(|line| {
2663 line.spans
2664 .iter()
2665 .map(|span| span.content.as_ref())
2666 .collect::<String>()
2667 })
2668 .collect::<Vec<_>>()
2669 .join("\n");
2670 for required in [
2671 "role verifier",
2672 "deepseek/deepseek-v4-flash",
2673 "reasoning high→max",
2674 "via agent_profile.model",
2675 "tokens 160 (provider-reported)",
2676 "tools 3",
2677 "duration 2s",
2678 ] {
2679 assert!(text.contains(required), "{width} lost {required}: {text}");
2680 }
2681 assert!(lines.iter().all(|line| line.width() <= usize::from(width)));
2682 }
2683 }
2684
2685 #[test]
2686 fn receipt_fields_flatten_controls_strip_ansi_and_redact_secrets() {
2687 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2688 panel.apply_json_event(&json!({
2689 "type": "task_started",
2690 "at_ms": 1_200,
2691 "task_id": "hostile",
2692 "resolved_role": "verifier\r\nFORGED ROLE",
2693 "resolved_provider": "\u{1b}[31mdeepseek\u{1b}[0m\tFORGED PROVIDER",
2694 "resolved_model": "model\napi_key=sk-receipt-secret-1234567890",
2695 "requested_reasoning": "high\rFORGED REASONING",
2696 "effective_reasoning": "max\tFORGED EFFECTIVE",
2697 "route_source": "task.model",
2698 "worktree": false,
2699 }));
2700 let row = panel
2701 .phases
2702 .iter()
2703 .flat_map(|phase| phase.rows.iter())
2704 .find(|row| row.task_id == "hostile")
2705 .expect("hostile row");
2706 let receipt = row_receipt_text(row);
2707
2708 assert!(receipt.contains("verifier FORGED ROLE"), "{receipt:?}");
2709 assert!(receipt.contains("deepseek FORGED PROVIDER"), "{receipt:?}");
2710 assert!(receipt.contains("api_key=[redacted]"), "{receipt:?}");
2711 assert!(!receipt.contains("sk-receipt-secret"), "{receipt:?}");
2712 assert!(!receipt.chars().any(char::is_control), "{receipt:?}");
2713 for line in receipt_line_strings(row, Locale::En, 18, 2) {
2714 assert!(!line.chars().any(char::is_control), "{line:?}");
2715 assert!(line.width() <= 18, "{line:?}");
2716 }
2717 }
2718
2719 fn started_panel() -> WorkflowPanel {
2720 let mut panel = WorkflowPanel::new("workflow_abc", "ship v0.8.68", 1_000);
2721 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
2722 title: "Analyze".to_string(),
2723 at_ms: 1_100,
2724 });
2725 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2726 task_id: "t1".to_string(),
2727 label: Some("scout crates".to_string()),
2728 profile: Some("explore".to_string()),
2729 model: Some("flash".to_string()),
2730 strength: Some("low".to_string()),
2731 resolved_model: Some("deepseek-v4-flash".to_string()),
2732 worktree: true,
2733 workspace: Some(PathBuf::from("/tmp/wt-1")),
2734 route: Box::default(),
2735 at_ms: 1_200,
2736 });
2737 panel
2738 }
2739
2740 #[test]
2741 fn late_run_completed_keeps_a_cancelled_run_cancelled() {
2742 let mut panel = started_panel();
2743 panel.apply_event(WorkflowPanelEvent::RunCancelled {
2744 reason: "stopped by you".to_string(),
2745 at_ms: 2_000,
2746 });
2747 panel.apply_event(WorkflowPanelEvent::RunCompleted {
2748 status: WorkflowPanelLifecycle::Succeeded,
2749 error: None,
2750 at_ms: 2_100,
2751 });
2752 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
2753 assert_eq!(panel.error.as_deref(), Some("stopped by you"));
2754 assert_eq!(panel.completed_at_ms, Some(2_000));
2755 assert_eq!(
2756 panel.find_row_mut("t1").expect("row").status,
2757 WorkflowRowStatus::Cancelled
2758 );
2759 }
2760
2761 /// U05-01: a child launch that raced the cancel (or any late same-run
2762 /// `task_started`) lands after the run settled. It is recorded, but it
2763 /// never reopens the run, and a settled row is never reset to running.
2764 #[test]
2765 fn late_task_started_never_reopens_a_settled_run() {
2766 let mut panel = started_panel();
2767 panel.apply_json_event(&json!({
2768 "type": "run_cancelled", "run_id": "workflow_abc",
2769 "reason": "stopped by you", "at_ms": 2_000,
2770 }));
2771 panel.apply_json_event(&task_started_json("t2", "deepseek", "flash"));
2772 panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
2773 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
2774 assert_eq!(
2775 panel.find_row_mut("t1").expect("t1").status,
2776 WorkflowRowStatus::Cancelled
2777 );
2778 assert_eq!(
2779 panel
2780 .find_row_mut("t2")
2781 .expect("late t2 is recorded")
2782 .status,
2783 WorkflowRowStatus::Cancelled
2784 );
2785
2786 let mut panel = started_panel();
2787 panel.apply_json_event(&json!({
2788 "type": "task_completed", "run_id": "workflow_abc",
2789 "task_id": "t1", "status": "succeeded", "at_ms": 1_500,
2790 }));
2791 panel.apply_json_event(&json!({
2792 "type": "run_completed", "run_id": "workflow_abc",
2793 "status": "completed", "at_ms": 1_600,
2794 }));
2795 panel.apply_json_event(&task_started_json("t1", "deepseek", "flash"));
2796 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
2797 assert_eq!(
2798 panel.find_row_mut("t1").expect("t1").status,
2799 WorkflowRowStatus::Succeeded
2800 );
2801 }
2802
2803 /// U05-m1: an arriving task joins the runtime's newest phase, never the
2804 /// phase the user's cursor happens to rest on.
2805 #[test]
2806 fn arriving_task_joins_the_newest_phase_not_the_selected_one() {
2807 let mut panel = started_panel();
2808 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
2809 title: "Verify".to_string(),
2810 at_ms: 1_300,
2811 });
2812 panel.selected_phase = 0;
2813 panel.apply_json_event(&task_started_json("t2", "deepseek", "flash"));
2814 let ids = |phase: &WorkflowPanelPhase| {
2815 phase
2816 .rows
2817 .iter()
2818 .map(|row| row.task_id.clone())
2819 .collect::<Vec<_>>()
2820 };
2821 assert_eq!(ids(&panel.phases[0]), ["t1"]);
2822 assert_eq!(ids(&panel.phases[1]), ["t2"]);
2823 }
2824
2825 /// U05-02: a terminal receipt with a missing or contradictory status is
2826 /// not evidence of success; it fails closed like an unknown status.
2827 #[test]
2828 fn malformed_completion_status_never_reads_as_success() {
2829 for status in [None, Some("running"), Some("pending")] {
2830 let mut panel = started_panel();
2831 let mut completed = json!({
2832 "type": "run_completed", "run_id": "workflow_abc", "at_ms": 2_000,
2833 });
2834 let mut task_completed = json!({
2835 "type": "task_completed", "run_id": "workflow_abc",
2836 "task_id": "t1", "at_ms": 1_900,
2837 });
2838 if let Some(status) = status {
2839 completed["status"] = json!(status);
2840 task_completed["status"] = json!(status);
2841 }
2842 panel.apply_json_event(&task_completed);
2843 panel.apply_json_event(&completed);
2844 assert_eq!(
2845 panel.lifecycle,
2846 WorkflowPanelLifecycle::Failed,
2847 "{status:?}"
2848 );
2849 assert_eq!(
2850 panel.find_row_mut("t1").expect("row").status,
2851 WorkflowRowStatus::Failed
2852 );
2853 }
2854 }
2855
2856 /// #4208: every decorative glyph the run map emits — expand marks, role
2857 /// marks, lane glyphs, gates, status marks across running, waiting,
2858 /// failed, cancelled, and completed members — must narrow to an
2859 /// ASCII-safe alternative.
2860 #[test]
2861 fn workflow_panel_glyphs_all_have_ascii_alternatives() {
2862 let mut panel = started_panel();
2863 for (task_id, status) in [
2864 ("t1", WorkflowRowStatus::Succeeded),
2865 ("t2", WorkflowRowStatus::Failed),
2866 ("t3", WorkflowRowStatus::Cancelled),
2867 ("t4", WorkflowRowStatus::Waiting),
2868 ] {
2869 if task_id != "t1" {
2870 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2871 task_id: task_id.to_string(),
2872 label: Some(format!("lane {task_id}")),
2873 profile: Some("implementer".to_string()),
2874 model: None,
2875 strength: None,
2876 resolved_model: None,
2877 worktree: false,
2878 workspace: None,
2879 route: Box::default(),
2880 at_ms: 1_400,
2881 });
2882 }
2883 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2884 task_id: task_id.to_string(),
2885 status,
2886 usage: None,
2887 reason: None,
2888 at_ms: 2_500,
2889 });
2890 }
2891 panel.apply_event(WorkflowPanelEvent::GateUpdated {
2892 gate_id: "gate-1".to_string(),
2893 role: Some("verifier".to_string()),
2894 gate: Some("tests-green".to_string()),
2895 state: "blocked".to_string(),
2896 blocked_role: Some("implementer".to_string()),
2897 blocked_reason: Some("waiting on tests".to_string()),
2898 at_ms: 2_600,
2899 });
2900
2901 let mut glyphs: Vec<char> = panel.compact_summary_text(120).chars().collect();
2902 for line in panel.render_history_card(100, true, &WorkflowHistoryExtras::default()) {
2903 for span in &line.spans {
2904 glyphs.extend(span.content.chars());
2905 }
2906 }
2907 for ch in glyphs.into_iter().filter(|ch| !ch.is_ascii()) {
2908 let mut cell = ratatui::buffer::Cell::default();
2909 cell.set_symbol(&ch.to_string());
2910 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
2911 assert!(
2912 cell.symbol().is_ascii(),
2913 "workflow glyph {ch:?} (U+{:04X}) lacks an ASCII-safe alternative",
2914 ch as u32
2915 );
2916 }
2917 }
2918
2919 #[test]
2920 fn a_repeated_start_of_the_same_run_keeps_settled_rows_and_outcome() {
2921 let mut panel = started_panel();
2922 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2923 task_id: "t1".to_string(),
2924 status: WorkflowRowStatus::Succeeded,
2925 usage: None,
2926 reason: None,
2927 at_ms: 1_400,
2928 });
2929 panel.apply_event(WorkflowPanelEvent::RunCompleted {
2930 status: WorkflowPanelLifecycle::Succeeded,
2931 error: None,
2932 at_ms: 1_500,
2933 });
2934 let rows = |panel: &WorkflowPanel| panel.phases.iter().map(|p| p.rows.len()).sum::<usize>();
2935 let settled_rows = rows(&panel);
2936 assert!(settled_rows > 0);
2937 assert!(panel.apply_json_event(&json!({
2938 "type": "run_started",
2939 "run_id": "workflow_abc",
2940 "at_ms": 1_600,
2941 "workflow_goal": "ship v0.8.68",
2942 "token_budget": 9_000
2943 })));
2944 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
2945 assert_eq!(rows(&panel), settled_rows);
2946 assert_eq!(
2947 panel.budget_total,
2948 Some(9_000),
2949 "missing metadata is filled"
2950 );
2951 }
2952
2953 #[test]
2954 fn json_events_round_trip_without_log_flood() {
2955 let mut panel = WorkflowPanel::new("w1", "goal", 0);
2956 let events = vec![
2957 json!({
2958 "type": "run_started",
2959 "at_ms": 10,
2960 "run_id": "w1",
2961 "workflow_goal": "demo",
2962 "token_budget": 5000
2963 }),
2964 json!({"type": "log", "at_ms": 11, "message": "should not appear"}),
2965 json!({"type": "phase_started", "at_ms": 12, "title": "Analyze"}),
2966 json!({
2967 "type": "task_started",
2968 "at_ms": 13,
2969 "task_id": "a",
2970 "label": "scout",
2971 "profile": "explore",
2972 "resolved_model": "flash",
2973 "worktree": true
2974 }),
2975 json!({
2976 "type": "budget_updated",
2977 "at_ms": 14,
2978 "total": 5000,
2979 "spent": 100,
2980 "remaining": 4900
2981 }),
2982 json!({
2983 "type": "task_completed",
2984 "at_ms": 15,
2985 "task_id": "a",
2986 "status": "succeeded"
2987 }),
2988 json!({
2989 "type": "gate_updated",
2990 "at_ms": 15,
2991 "gate_id": "reviewer-diff",
2992 "role": "reviewer",
2993 "gate": "review",
2994 "state": "blocked",
2995 "blocked_role": "verifier",
2996 "blocked_reason": "review found regression"
2997 }),
2998 json!({
2999 "type": "run_completed",
3000 "at_ms": 16,
3001 "status": "completed"
3002 }),
3003 ];
3004 panel.apply_json_events(&events);
3005 assert_eq!(panel.label, "demo");
3006 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
3007 assert_eq!(panel.budget_spent, 100);
3008 assert_eq!(panel.budget_remaining, Some(4900));
3009 let joined: String = panel
3010 .render_history_card(100, true, &WorkflowHistoryExtras::default())
3011 .iter()
3012 .map(|l| {
3013 l.spans
3014 .iter()
3015 .map(|s| s.content.as_ref())
3016 .collect::<String>()
3017 })
3018 .collect::<Vec<_>>()
3019 .join("\n");
3020 assert!(!joined.contains("should not appear"), "{joined}");
3021 assert!(joined.contains("scout"), "{joined}");
3022 assert!(joined.contains("done"), "{joined}");
3023 assert!(joined.contains("reviewer-diff"), "{joined}");
3024 assert!(joined.contains("review found regression"), "{joined}");
3025 }
3026
3027 #[test]
3028 fn failure_and_cancel_counts_roll_up_in_header() {
3029 let mut panel = started_panel();
3030 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3031 task_id: "t2".to_string(),
3032 label: Some("b".to_string()),
3033 profile: None,
3034 model: None,
3035 strength: None,
3036 resolved_model: None,
3037 worktree: false,
3038 workspace: None,
3039 route: Box::default(),
3040 at_ms: 1_300,
3041 });
3042 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3043 task_id: "t1".to_string(),
3044 status: WorkflowRowStatus::Failed,
3045 usage: None,
3046 reason: None,
3047 at_ms: 1_400,
3048 });
3049 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3050 task_id: "t2".to_string(),
3051 status: WorkflowRowStatus::Cancelled,
3052 usage: None,
3053 reason: None,
3054 at_ms: 1_500,
3055 });
3056 let (failed, cancelled) = panel.failure_cancel_counts();
3057 assert_eq!(failed, 1);
3058 assert_eq!(cancelled, 1);
3059 let header = panel.compact_summary_text(100);
3060 assert!(header.contains("1 fail"), "{header}");
3061 assert!(header.contains("1 cancel"), "{header}");
3062 assert!(header.contains("2/2"), "{header}");
3063 }
3064
3065 #[test]
3066 fn task_started_json_prefers_workflow_task_label_over_generic_label() {
3067 // #4119: panel rows use typed workflow metadata, not prompt text.
3068 let event = WorkflowPanelEvent::from_json_value(&json!({
3069 "type": "task_started",
3070 "task_id": "child-1",
3071 "label": "fallback-label",
3072 "workflow_task_label": "typed-label",
3073 "workflow_run_id": "run-xyz",
3074 "workflow_phase_id": "dispatch",
3075 "workflow_child_index": 2,
3076 "at_ms": 42,
3077 }))
3078 .expect("task_started parses");
3079 match event {
3080 WorkflowPanelEvent::TaskStarted { label, .. } => {
3081 assert_eq!(label.as_deref(), Some("typed-label"));
3082 }
3083 other => panic!("expected TaskStarted, got {other:?}"),
3084 }
3085
3086 let mut panel = WorkflowPanel::new("run-xyz", "goal", 1);
3087 panel.apply_json_event(&json!({
3088 "type": "task_started",
3089 "task_id": "child-1",
3090 "label": "fallback-label",
3091 "workflow_task_label": "typed-label",
3092 "at_ms": 42,
3093 }));
3094 let row = panel
3095 .phases
3096 .iter()
3097 .flat_map(|phase| phase.rows.iter())
3098 .find(|row| row.task_id == "child-1")
3099 .expect("row recorded");
3100 assert_eq!(row.label, "typed-label");
3101 }
3102
3103 #[test]
3104 fn explicit_event_run_id_cannot_cross_panel_run() {
3105 let mut panel = WorkflowPanel::new("run-b", "active run", 2_000);
3106
3107 assert!(!panel.apply_json_event(&json!({
3108 "type": "run_started",
3109 "run_id": "run-a",
3110 "workflow_goal": "late prior run",
3111 "at_ms": 1_500,
3112 })));
3113 assert!(!panel.apply_json_event(&json!({
3114 "type": "phase_started",
3115 "run_id": "run-a",
3116 "title": "Late A phase",
3117 "at_ms": 2_100,
3118 })));
3119 assert!(panel.phases.is_empty());
3120 assert_eq!(panel.run_id, "run-b");
3121
3122 assert!(panel.apply_json_event(&json!({
3123 "type": "phase_started",
3124 "run_id": "run-b",
3125 "title": "B phase",
3126 "at_ms": 2_200,
3127 })));
3128 assert_eq!(panel.phases.len(), 1);
3129 assert_eq!(panel.phases[0].title, "B phase");
3130 }
3131
3132 #[test]
3133 fn dispatch_failure_event_surfaces_without_inventing_a_child() {
3134 let mut panel = started_panel();
3135 panel.apply_json_event(&json!({
3136 "type": "task_dispatch_failed",
3137 "label": "review docs",
3138 "phase": "Analyze",
3139 "message": "unknown agent profile reviewer",
3140 "at_ms": 1_250,
3141 }));
3142
3143 assert_eq!(panel.done_total(), (0, 1), "rejected launch is not a child");
3144 assert_eq!(panel.failure_cancel_counts(), (1, 0));
3145 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Running);
3146 assert!(panel.compact_summary_text(120).contains("1 fail"));
3147
3148 let live = panel
3149 .render_history_card(120, true, &WorkflowHistoryExtras::default())
3150 .iter()
3151 .map(|line| {
3152 line.spans
3153 .iter()
3154 .map(|span| span.content.as_ref())
3155 .collect::<String>()
3156 })
3157 .collect::<Vec<_>>()
3158 .join("\n");
3159 for expected in [
3160 "dispatch failed",
3161 "review docs",
3162 "Analyze",
3163 "unknown agent profile reviewer",
3164 ] {
3165 assert!(live.contains(expected), "missing {expected}: {live}");
3166 }
3167
3168 let snapshot = panel.to_run_json();
3169 assert_eq!(snapshot["dispatch_failure_count"], 1);
3170 assert_eq!(
3171 snapshot["dispatch_failures"].as_array().map(Vec::len),
3172 Some(1)
3173 );
3174 let restored = WorkflowPanel::from_run_json(&snapshot).expect("panel rehydrates");
3175 assert_eq!(restored.done_total(), (0, 1));
3176 assert_eq!(restored.failure_cancel_counts(), (1, 0));
3177 let history = restored
3178 .render_history_card(120, true, &WorkflowHistoryExtras::default())
3179 .iter()
3180 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
3181 .collect::<String>();
3182 assert!(history.contains("dispatch failed"), "{history}");
3183 assert!(
3184 history.contains("unknown agent profile reviewer"),
3185 "{history}"
3186 );
3187
3188 let mut japanese = restored.clone();
3189 japanese.locale = Locale::Ja;
3190 let localized = japanese
3191 .render_history_card(120, true, &WorkflowHistoryExtras::default())
3192 .iter()
3193 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
3194 .collect::<String>();
3195 assert!(localized.contains("ディスパッチ失敗"), "{localized}");
3196 assert!(!localized.contains("dispatch failed"), "{localized}");
3197 }
3198
3199 #[test]
3200 fn degraded_run_preserves_partial_success_as_a_distinct_terminal_state() {
3201 let mut panel = started_panel();
3202 panel.apply_json_event(&json!({
3203 "type": "task_completed",
3204 "task_id": "t1",
3205 "status": "succeeded",
3206 "at_ms": 1_300,
3207 }));
3208 panel.apply_json_event(&json!({
3209 "type": "task_dispatch_failed",
3210 "label": "review docs",
3211 "message": "profile unavailable",
3212 "at_ms": 1_350,
3213 }));
3214 panel.apply_json_event(&json!({
3215 "type": "run_completed",
3216 "status": "degraded",
3217 "error": "completed with dropped slots",
3218 "at_ms": 1_400,
3219 }));
3220
3221 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Degraded);
3222 assert!(panel.lifecycle.is_terminal());
3223 assert_eq!(panel.done_total(), (1, 1));
3224 assert_eq!(panel.failure_cancel_counts(), (1, 0));
3225 assert!(panel.compact_summary_text(120).contains("degraded"));
3226
3227 panel.locale = Locale::Ja;
3228 assert!(panel.compact_summary_text(120).contains("一部失敗"));
3229 }
3230
3231 #[test]
3232 fn dispatch_failure_tail_is_bounded_and_redacted() {
3233 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
3234 for index in 0..20 {
3235 panel.apply_json_event(&json!({
3236 "type": "task_dispatch_failed",
3237 "label": format!("job-{index}"),
3238 "message": if index == 19 {
3239 "\u{1b}[31mapi_key=sk-dispatch-secret-1234567890\u{1b}[0m\nfailed"
3240 } else {
3241 "profile unavailable"
3242 },
3243 "at_ms": 1_100 + index,
3244 }));
3245 }
3246
3247 assert_eq!(panel.dispatch_failure_count, 20);
3248 assert_eq!(
3249 panel.dispatch_failures.len(),
3250 MAX_DISPATCH_FAILURES_RETAINED
3251 );
3252 assert_eq!(
3253 panel
3254 .dispatch_failures
3255 .first()
3256 .and_then(|failure| failure.label.as_deref()),
3257 Some("job-8")
3258 );
3259 let latest = panel.dispatch_failures.last().expect("latest failure");
3260 assert!(!latest.message.contains("sk-dispatch-secret"));
3261 assert!(!latest.message.chars().any(char::is_control));
3262 let rendered = panel
3263 .render_history_card(120, true, &WorkflowHistoryExtras::default())
3264 .iter()
3265 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
3266 .collect::<String>();
3267 assert!(rendered.contains("17 earlier not shown"), "{rendered}");
3268 assert!(!rendered.contains("sk-dispatch-secret"), "{rendered}");
3269 }
3270
3271 #[test]
3272 fn run_json_overlap_does_not_double_count_dispatch_failure_ledger() {
3273 let failure = json!({
3274 "type": "task_dispatch_failed",
3275 "label": "review docs",
3276 "phase": "Analyze",
3277 "message": "profile unavailable",
3278 "at_ms": 1_250,
3279 });
3280 let panel = WorkflowPanel::from_run_json(&json!({
3281 "run_id": "workflow_abc",
3282 "workflow_goal": "audit",
3283 "started_at_ms": 1_000,
3284 "events": [
3285 {
3286 "type": "run_started",
3287 "run_id": "workflow_abc",
3288 "workflow_goal": "audit",
3289 "at_ms": 1_000,
3290 },
3291 failure.clone(),
3292 ],
3293 "dispatch_failure_count": 1,
3294 "dispatch_failures": [{
3295 "label": "review docs",
3296 "phase": "Analyze",
3297 "message": "profile unavailable",
3298 "at_ms": 1_250,
3299 }],
3300 }))
3301 .expect("panel rehydrates");
3302
3303 assert_eq!(panel.dispatch_failure_count, 1);
3304 assert_eq!(panel.dispatch_failures.len(), 1);
3305 assert_eq!(panel.failure_cancel_counts(), (1, 0));
3306
3307 let mut live = WorkflowPanel::new("workflow_abc", "audit", 1_000);
3308 live.apply_json_event(&failure);
3309 live.apply_json_events(std::slice::from_ref(&failure));
3310 live.merge_dispatch_failures_from_run_json(&json!({
3311 "dispatch_failure_count": 1,
3312 "dispatch_failures": [{
3313 "label": "review docs",
3314 "phase": "Analyze",
3315 "message": "profile unavailable",
3316 "at_ms": 1_250,
3317 }],
3318 }));
3319 assert_eq!(
3320 live.dispatch_failure_count, 1,
3321 "authoritative completion ledger must absorb retained replay"
3322 );
3323 live.apply_json_events(&[failure.clone(), failure]);
3324 live.merge_dispatch_failures_from_run_json(&json!({
3325 "dispatch_failure_count": 2,
3326 "dispatch_failures": [
3327 {
3328 "label": "review docs",
3329 "phase": "Analyze",
3330 "message": "profile unavailable",
3331 "at_ms": 1_250,
3332 },
3333 {
3334 "label": "review docs",
3335 "phase": "Analyze",
3336 "message": "profile unavailable",
3337 "at_ms": 1_250,
3338 },
3339 ],
3340 }));
3341 assert_eq!(
3342 live.dispatch_failure_count, 2,
3343 "authoritative count must preserve two genuinely identical slots"
3344 );
3345 }
3346
3347 #[test]
3348 fn imported_max_dispatch_count_cannot_overflow_failed_child_rollup() {
3349 let mut panel = started_panel();
3350 panel.dispatch_failure_count = usize::MAX;
3351 panel.find_row_mut("t1").expect("row").status = WorkflowRowStatus::Failed;
3352 assert_eq!(panel.failure_cancel_counts(), (usize::MAX, 0));
3353 }
3354
3355 #[test]
3356 fn compact_history_card_summarizes_lifecycle_children_phases_failures_elapsed() {
3357 let mut panel = started_panel();
3358 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3359 task_id: "t1".to_string(),
3360 status: WorkflowRowStatus::Failed,
3361 usage: None,
3362 reason: None,
3363 at_ms: 2_000,
3364 });
3365 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3366 status: WorkflowPanelLifecycle::Failed,
3367 error: Some("scout failed".to_string()),
3368 at_ms: 2_100,
3369 });
3370 let lines = panel.render_history_card(120, false, &WorkflowHistoryExtras::default());
3371 assert_eq!(lines.len(), 1, "compact is a single summary line");
3372 let joined: String = lines
3373 .iter()
3374 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3375 .collect();
3376 assert!(joined.contains('▶'), "collapsed glyph: {joined}");
3377 assert!(
3378 joined.contains("failed") || joined.contains("fail"),
3379 "{joined}"
3380 );
3381 assert!(joined.contains("1/1 done"), "{joined}");
3382 assert!(joined.contains("1 phase"), "{joined}");
3383 assert!(joined.contains("1 fail"), "{joined}");
3384 // elapsed is present (0s or more depending on timestamps)
3385 assert!(
3386 joined.contains('s') || joined.contains('m'),
3387 "elapsed time expected: {joined}"
3388 );
3389 // Goal is reserved for the expanded body so compact stays under the
3390 // tool-header summary budget.
3391 assert!(
3392 !joined.contains("ship v0.8.68"),
3393 "compact must not spend budget on free-text goal: {joined}"
3394 );
3395 }
3396
3397 #[test]
3398 fn expanded_history_card_shows_phase_child_result_links_and_failures() {
3399 let mut panel = started_panel();
3400 panel.source_path = Some(PathBuf::from("workflows/demo.workflow.js"));
3401 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3402 task_id: "t1".to_string(),
3403 status: WorkflowRowStatus::Failed,
3404 usage: None,
3405 reason: None,
3406 at_ms: 2_000,
3407 });
3408 if let Some(row) = panel.find_row_mut("t1") {
3409 row.error = Some("timeout waiting for model".to_string());
3410 }
3411 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3412 status: WorkflowPanelLifecycle::Failed,
3413 error: Some("phase Analyze failed".to_string()),
3414 at_ms: 2_100,
3415 });
3416 let extras = WorkflowHistoryExtras {
3417 result_summary: Some("no ship blockers found".to_string()),
3418 source_path: None,
3419 spillover_path: Some(PathBuf::from("/tmp/workflow-out.json")),
3420 verification_summary: None,
3421 };
3422 let lines = panel.render_history_card(120, true, &extras);
3423 let joined: String = lines
3424 .iter()
3425 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3426 .collect::<Vec<_>>()
3427 .join("\n");
3428 assert!(joined.contains('▼'), "expanded glyph: {joined}");
3429 assert!(joined.contains("goal:"), "{joined}");
3430 assert!(joined.contains("ship v0.8.68"), "{joined}");
3431 assert!(joined.contains("phases:"), "{joined}");
3432 assert!(joined.contains("Analyze"), "{joined}");
3433 assert!(joined.contains("children:"), "{joined}");
3434 assert!(joined.contains("scout crates"), "{joined}");
3435 assert!(joined.contains("result:"), "{joined}");
3436 assert!(joined.contains("no ship blockers"), "{joined}");
3437 assert!(
3438 joined.contains("source:") || joined.contains("demo.workflow"),
3439 "{joined}"
3440 );
3441 assert!(joined.contains("artifact:"), "{joined}");
3442 assert!(joined.contains("error:"), "{joined}");
3443 assert!(joined.contains("phase Analyze failed"), "{joined}");
3444 assert!(
3445 joined.contains("fail") || joined.contains("timeout"),
3446 "{joined}"
3447 );
3448 }
3449
3450 #[test]
3451 fn direct_subagent_card_reuses_history_renderer() {
3452 let panel = WorkflowPanel::from_direct_subagent(
3453 "agent_abc",
3454 "explore",
3455 WorkflowPanelLifecycle::Succeeded,
3456 1_000,
3457 Some(4_500),
3458 Some("found 3 call sites".to_string()),
3459 None,
3460 );
3461 let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default());
3462 let joined: String = compact
3463 .iter()
3464 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3465 .collect();
3466 assert!(
3467 joined.contains("success") || joined.contains("explore"),
3468 "{joined}"
3469 );
3470 assert!(joined.contains("1/1 done"), "{joined}");
3471 assert!(joined.contains("3s") || joined.contains("s"), "{joined}");
3472
3473 let expanded = panel.render_history_card(
3474 100,
3475 true,
3476 &WorkflowHistoryExtras {
3477 result_summary: Some("found 3 call sites".to_string()),
3478 ..WorkflowHistoryExtras::default()
3479 },
3480 );
3481 let joined: String = expanded
3482 .iter()
3483 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3484 .collect::<Vec<_>>()
3485 .join("\n");
3486 assert!(joined.contains("children:"), "{joined}");
3487 assert!(joined.contains("result:"), "{joined}");
3488 assert!(joined.contains("found 3 call sites"), "{joined}");
3489 assert!(!joined.contains("reasoning unknown"), "{joined}");
3490 assert!(!joined.contains("via unknown"), "{joined}");
3491 assert!(!joined.contains("tokens unknown"), "{joined}");
3492 assert!(
3493 joined.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
3494 "history details hint must use the platform chord: {joined}"
3495 );
3496 assert!(!joined.contains("details (v)"), "{joined}");
3497 }
3498
3499 #[test]
3500 fn from_run_json_round_trips_events_into_history_card() {
3501 let value = json!({
3502 "run_id": "workflow_demo",
3503 "status": "completed",
3504 "workflow_goal": "ship it",
3505 "started_at_ms": 1000,
3506 "completed_at_ms": 5000,
3507 "events": [
3508 {
3509 "type": "run_started",
3510 "at_ms": 1000,
3511 "run_id": "workflow_demo",
3512 "workflow_goal": "ship it"
3513 },
3514 {"type": "phase_started", "at_ms": 1100, "title": "Build"},
3515 {
3516 "type": "task_started",
3517 "at_ms": 1200,
3518 "task_id": "t1",
3519 "label": "compile",
3520 "profile": "implementer"
3521 },
3522 {
3523 "type": "task_completed",
3524 "at_ms": 4000,
3525 "task_id": "t1",
3526 "status": "succeeded"
3527 },
3528 {"type": "run_completed", "at_ms": 5000, "status": "completed"}
3529 ]
3530 });
3531 let panel = WorkflowPanel::from_run_json(&value).expect("hydrate");
3532 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
3533 let compact = panel.compact_summary_text(120);
3534 assert!(compact.contains("1/1 done"), "{compact}");
3535 assert!(compact.contains("success"), "{compact}");
3536 let expanded = panel.history_expanded_lines(120, &WorkflowHistoryExtras::default());
3537 let joined: String = expanded
3538 .iter()
3539 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3540 .collect::<Vec<_>>()
3541 .join("\n");
3542 assert!(joined.contains("goal:"), "{joined}");
3543 assert!(joined.contains("ship it"), "{joined}");
3544 assert!(joined.contains("Build"), "{joined}");
3545 assert!(joined.contains("compile"), "{joined}");
3546 }
3547
3548 // ── #4131 dogfood scenario projections ──────────────────────────────────
3549
3550 /// WF-A1: read-only repo audit — scout phase on main workspace, labeled
3551 /// children, no worktree marker, synthesizer phase present.
3552 #[test]
3553 fn dogfood_read_only_repo_audit_panel() {
3554 let mut panel = WorkflowPanel::new("wf_a1", "read-only repo audit", 1_000);
3555 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3556 title: "Scout".to_string(),
3557 at_ms: 1_100,
3558 });
3559 for (id, label, role) in [
3560 ("t1", "map crates", "explore"),
3561 ("t2", "scan unsafe", "explore"),
3562 ("t3", "scan unwrap", "explore"),
3563 ] {
3564 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3565 task_id: id.to_string(),
3566 label: Some(label.to_string()),
3567 profile: Some(role.to_string()),
3568 model: Some("flash".to_string()),
3569 strength: Some("low".to_string()),
3570 resolved_model: Some("deepseek-v4-flash".to_string()),
3571 worktree: false,
3572 workspace: None,
3573 route: Box::default(),
3574 at_ms: 1_200,
3575 });
3576 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3577 task_id: id.to_string(),
3578 status: WorkflowRowStatus::Succeeded,
3579 usage: None,
3580 reason: None,
3581 at_ms: 1_500,
3582 });
3583 }
3584 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3585 title: "Synthesize".to_string(),
3586 at_ms: 1_600,
3587 });
3588 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3589 task_id: "t4".to_string(),
3590 label: Some("audit summary".to_string()),
3591 profile: Some("general".to_string()),
3592 model: None,
3593 strength: None,
3594 resolved_model: None,
3595 worktree: false,
3596 workspace: None,
3597 route: Box::default(),
3598 at_ms: 1_700,
3599 });
3600 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3601 task_id: "t4".to_string(),
3602 status: WorkflowRowStatus::Succeeded,
3603 usage: None,
3604 reason: None,
3605 at_ms: 2_000,
3606 });
3607 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3608 status: WorkflowPanelLifecycle::Succeeded,
3609 error: None,
3610 at_ms: 2_100,
3611 });
3612
3613 let header = panel.compact_summary_text(140);
3614 assert!(
3615 header.contains("success") || header.contains("completed"),
3616 "{header}"
3617 );
3618 assert!(!header.contains("fail"), "{header}");
3619 assert!(
3620 header.contains("4/") || header.contains("4 child") || header.contains("0/"),
3621 "{header}"
3622 );
3623
3624 // Selected phase is Synthesize; scout labels live in earlier phases.
3625 panel.selected_phase = 0;
3626 let scout_body = panel.render_history_card(120, true, &WorkflowHistoryExtras::default());
3627 let scout_joined: String = scout_body
3628 .iter()
3629 .map(|l| {
3630 l.spans
3631 .iter()
3632 .map(|s| s.content.as_ref())
3633 .collect::<String>()
3634 })
3635 .collect::<Vec<_>>()
3636 .join("\n");
3637 assert!(scout_joined.contains("map crates"), "{scout_joined}");
3638 assert!(
3639 !scout_joined.contains(" wt "),
3640 "read-only scouts stay on main: {scout_joined}"
3641 );
3642
3643 let card = panel.render_history_card(
3644 120,
3645 true,
3646 &WorkflowHistoryExtras {
3647 result_summary: Some("no critical issues".to_string()),
3648 ..WorkflowHistoryExtras::default()
3649 },
3650 );
3651 let card_text: String = card
3652 .iter()
3653 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3654 .collect::<Vec<_>>()
3655 .join("\n");
3656 assert!(
3657 card_text.contains("Scout") || card_text.contains("Synthesize"),
3658 "{card_text}"
3659 );
3660 assert!(card_text.contains("no critical issues"), "{card_text}");
3661 assert!(
3662 !card_text.to_ascii_lowercase().contains("unknown child"),
3663 "{card_text}"
3664 );
3665 }
3666
3667 /// WF-A2: staged bugfix — implementer worktree + verifier on main.
3668 #[test]
3669 fn dogfood_staged_worktree_implementer_verifier() {
3670 let mut panel = WorkflowPanel::new("wf_a2", "staged docs fix", 1_000);
3671 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3672 title: "Implement".to_string(),
3673 at_ms: 1_100,
3674 });
3675 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3676 task_id: "impl".to_string(),
3677 label: Some("implementer".to_string()),
3678 profile: Some("implementer".to_string()),
3679 model: Some("pro".to_string()),
3680 strength: None,
3681 resolved_model: Some("deepseek-v4-pro".to_string()),
3682 worktree: true,
3683 workspace: Some(PathBuf::from("/tmp/wt-impl")),
3684 route: Box::default(),
3685 at_ms: 1_200,
3686 });
3687 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3688 task_id: "impl".to_string(),
3689 status: WorkflowRowStatus::Succeeded,
3690 usage: None,
3691 reason: None,
3692 at_ms: 2_000,
3693 });
3694 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3695 title: "Verify".to_string(),
3696 at_ms: 2_100,
3697 });
3698 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3699 task_id: "ver".to_string(),
3700 label: Some("verifier".to_string()),
3701 profile: Some("verifier".to_string()),
3702 model: Some("flash".to_string()),
3703 strength: None,
3704 resolved_model: None,
3705 worktree: false,
3706 workspace: None,
3707 route: Box::default(),
3708 at_ms: 2_200,
3709 });
3710 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3711 task_id: "ver".to_string(),
3712 status: WorkflowRowStatus::Succeeded,
3713 usage: None,
3714 reason: None,
3715 at_ms: 3_000,
3716 });
3717 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3718 status: WorkflowPanelLifecycle::Succeeded,
3719 error: None,
3720 at_ms: 3_100,
3721 });
3722
3723 assert_eq!(panel.phases.len(), 2);
3724 assert_eq!(panel.phases[0].title, "Implement");
3725 assert_eq!(panel.phases[1].title, "Verify");
3726
3727 panel.selected_phase = 0;
3728 let implement_body =
3729 panel.render_history_card(140, true, &WorkflowHistoryExtras::default());
3730 let impl_text: String = implement_body
3731 .iter()
3732 .map(|l| {
3733 l.spans
3734 .iter()
3735 .map(|s| s.content.as_ref())
3736 .collect::<String>()
3737 })
3738 .collect::<Vec<_>>()
3739 .join("\n");
3740 assert!(impl_text.contains("implementer"), "{impl_text}");
3741
3742 panel.selected_phase = 1;
3743 let verify_body = panel.render_history_card(140, true, &WorkflowHistoryExtras::default());
3744 let ver_text: String = verify_body
3745 .iter()
3746 .map(|l| {
3747 l.spans
3748 .iter()
3749 .map(|s| s.content.as_ref())
3750 .collect::<String>()
3751 })
3752 .collect::<Vec<_>>()
3753 .join("\n");
3754 assert!(ver_text.contains("verifier"), "{ver_text}");
3755 assert!(
3756 !ver_text.contains(" wt"),
3757 "verifier stays on main: {ver_text}"
3758 );
3759 }
3760
3761 /// WF-A3: partial failure + synthesis — fail count visible, summary card.
3762 #[test]
3763 fn dogfood_partial_failure_and_synthesis() {
3764 let mut panel = WorkflowPanel::new("wf_a3", "partial failure synthesis", 1_000);
3765 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3766 title: "Parallel scouts".to_string(),
3767 at_ms: 1_100,
3768 });
3769 for (id, label, status) in [
3770 ("a", "scout-a", WorkflowRowStatus::Succeeded),
3771 ("b", "scout-b-fail", WorkflowRowStatus::Failed),
3772 ("c", "scout-c", WorkflowRowStatus::Succeeded),
3773 ] {
3774 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3775 task_id: id.to_string(),
3776 label: Some(label.to_string()),
3777 profile: Some("explore".to_string()),
3778 model: None,
3779 strength: None,
3780 resolved_model: None,
3781 worktree: false,
3782 workspace: None,
3783 route: Box::default(),
3784 at_ms: 1_200,
3785 });
3786 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3787 task_id: id.to_string(),
3788 status,
3789 usage: None,
3790 reason: None,
3791 at_ms: 1_500,
3792 });
3793 }
3794 if let Some(row) = panel.find_row_mut("b") {
3795 row.error = Some("scout refused to produce summary".to_string());
3796 }
3797 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3798 title: "Synthesize".to_string(),
3799 at_ms: 1_600,
3800 });
3801 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3802 task_id: "syn".to_string(),
3803 label: Some("synthesizer".to_string()),
3804 profile: Some("general".to_string()),
3805 model: None,
3806 strength: None,
3807 resolved_model: None,
3808 worktree: false,
3809 workspace: None,
3810 route: Box::default(),
3811 at_ms: 1_700,
3812 });
3813 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3814 task_id: "syn".to_string(),
3815 status: WorkflowRowStatus::Succeeded,
3816 usage: None,
3817 reason: None,
3818 at_ms: 2_000,
3819 });
3820 // Partial success at run level: completed with surviving synthesis.
3821 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3822 status: WorkflowPanelLifecycle::Succeeded,
3823 error: None,
3824 at_ms: 2_100,
3825 });
3826
3827 let (failed, cancelled) = panel.failure_cancel_counts();
3828 assert_eq!(failed, 1, "exactly one parallel slot failed");
3829 assert_eq!(cancelled, 0);
3830 let header = panel.compact_summary_text(140);
3831 assert!(header.contains("1 fail"), "{header}");
3832
3833 let card = panel.render_history_card(
3834 140,
3835 true,
3836 &WorkflowHistoryExtras {
3837 result_summary: Some("2/3 scouts ok; scout-b failed".to_string()),
3838 ..WorkflowHistoryExtras::default()
3839 },
3840 );
3841 let joined: String = card
3842 .iter()
3843 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3844 .collect::<Vec<_>>()
3845 .join("\n");
3846 assert!(
3847 joined.contains("scout-b-fail") || joined.contains("fail"),
3848 "{joined}"
3849 );
3850 assert!(joined.contains("2/3 scouts ok"), "{joined}");
3851 }
3852
3853 /// WF-A4: cancellation mid-run — running children cancelled, done preserved.
3854 #[test]
3855 fn dogfood_cancellation_mid_run() {
3856 let mut panel = WorkflowPanel::new("wf_a4", "cancel mid-run", 1_000);
3857 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3858 title: "Long work".to_string(),
3859 at_ms: 1_100,
3860 });
3861 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3862 task_id: "slow-1".to_string(),
3863 label: Some("slow-1".to_string()),
3864 profile: Some("explore".to_string()),
3865 model: None,
3866 strength: None,
3867 resolved_model: None,
3868 worktree: false,
3869 workspace: None,
3870 route: Box::default(),
3871 at_ms: 1_200,
3872 });
3873 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3874 task_id: "slow-2".to_string(),
3875 label: Some("slow-2".to_string()),
3876 profile: Some("explore".to_string()),
3877 model: None,
3878 strength: None,
3879 resolved_model: None,
3880 worktree: false,
3881 workspace: None,
3882 route: Box::default(),
3883 at_ms: 1_210,
3884 });
3885 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3886 task_id: "slow-1".to_string(),
3887 status: WorkflowRowStatus::Succeeded,
3888 usage: None,
3889 reason: None,
3890 at_ms: 1_500,
3891 });
3892
3893 // A confirmed host interrupt finalizes remaining runners. The widget
3894 // itself never claims cancellation before that runtime event.
3895 panel.apply_event(WorkflowPanelEvent::RunCancelled {
3896 reason: "interrupted".to_string(),
3897 at_ms: now_ms(),
3898 });
3899 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
3900
3901 let slow1 = panel
3902 .phases
3903 .iter()
3904 .flat_map(|p| p.rows.iter())
3905 .find(|r| r.task_id == "slow-1")
3906 .expect("slow-1");
3907 let slow2 = panel
3908 .phases
3909 .iter()
3910 .flat_map(|p| p.rows.iter())
3911 .find(|r| r.task_id == "slow-2")
3912 .expect("slow-2");
3913 assert_eq!(slow1.status, WorkflowRowStatus::Succeeded);
3914 assert_eq!(slow2.status, WorkflowRowStatus::Cancelled);
3915
3916 let (failed, cancelled) = panel.failure_cancel_counts();
3917 assert_eq!(failed, 0);
3918 assert_eq!(cancelled, 1);
3919 let header = panel.compact_summary_text(120);
3920 assert!(
3921 header.contains("cancel") || header.contains("cancelled"),
3922 "{header}"
3923 );
3924 }
3925 }
3926
3926 lines RUST