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