返回 CodeWhale
workflows_manager.rs
根目录 / crates / tui / src / tui / views / workflows_manager.rs
1 //! `/workflows` — the live workflow **run** dashboard.
2 //!
3 //! Lists every run this workspace's journal knows for the current session —
4 //! active and retained, not saved definitions — with the run's status, label,
5 //! elapsed time, child count, and latest progress. The detail pane adds the
6 //! phase order, the child-agent roster, the retained progress tail, and the
7 //! run id `/workflow cancel` accepts. `x` cancels the selected running run
8 //! through the same host path as `/workflow cancel` (no model turn, no
9 //! confirmation friction — matching the workflow panel's one-press cancel);
10 //! `r` re-reads the journal.
11 //!
12 //! This view never asks the model anything: it reads
13 //! [`crate::tools::workflow::host_workflow_run_details`] and writes only
14 //! through [`crate::tools::workflow::host_cancel_workflow`].
15
16 use std::cell::Cell;
17 use std::path::PathBuf;
18 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
21 use ratatui::{
22 buffer::Buffer,
23 layout::{Constraint, Direction, Layout, Rect},
24 style::{Modifier, Style},
25 text::{Line, Span},
26 widgets::{Block, Clear, Paragraph, Widget, Wrap},
27 };
28
29 use super::{ActionHint, ModalKind, ModalView, ViewAction, render_modal_footer};
30 use crate::tools::workflow::{
31 HostWorkflowChildRow, HostWorkflowRunDetail, host_cancel_workflow, host_workflow_run_details,
32 };
33 use crate::tui::app::App;
34 use crate::tui::list_nav::wrap_index;
35 use codewhale_palette as palette;
36
37 fn now_ms() -> u64 {
38 SystemTime::now()
39 .duration_since(UNIX_EPOCH)
40 .map(|d| d.as_millis() as u64)
41 .unwrap_or_default()
42 }
43
44 /// Status ink follows the workflow panel's grammar: running is working ink,
45 /// completion is outcome ink, only failure (including budget and replay
46 /// failures) spends red.
47 fn status_style(status: &str) -> Style {
48 match status {
49 "queued" | "running" | "waiting" | "pending" => {
50 Style::default().fg(palette::STATUS_WARNING)
51 }
52 "completed" | "succeeded" => Style::default().fg(palette::STATUS_SUCCESS),
53 "degraded" => Style::default().fg(palette::STATUS_WARNING),
54 "failed" | "budget_exceeded" => Style::default().fg(palette::STATUS_ERROR),
55 _ => Style::default().fg(palette::TEXT_MUTED),
56 }
57 }
58
59 /// One glyph per projected child state. Only a real failure reads as `✗`:
60 /// a cancellation is not a failure, and a state this view does not know
61 /// must not borrow the failure mark (U09-m3).
62 fn child_state_glyph(state: &str) -> &'static str {
63 match state {
64 "running" | "pending" => "•",
65 "succeeded" => "✓",
66 "failed" | "budget_exceeded" => "✗",
67 "cancelled" => "–",
68 _ => "?",
69 }
70 }
71
72 /// A child with no completion receipt in a settled run did not finish as far
73 /// as this record shows; it is not still running (U09-m3).
74 fn child_display_state(child: &HostWorkflowChildRow, run_active: bool) -> &'static str {
75 if !run_active && matches!(child.state, "running" | "pending") {
76 "unknown"
77 } else {
78 child.state
79 }
80 }
81
82 /// Open `/workflows` unless it is already on top. `/workflows`, the
83 /// workbar's `↓ to manage`, and a click on a workbar row all land here.
84 pub(crate) fn open(app: &mut App) {
85 if app.view_stack.top_kind() != Some(ModalKind::WorkflowsManager) {
86 let view = WorkflowsManagerView::new(app);
87 app.view_stack.push(view);
88 }
89 app.needs_redraw = true;
90 }
91
92 pub struct WorkflowsManagerView {
93 /// Newest first, so the live run is the first thing read.
94 runs: Vec<HostWorkflowRunDetail>,
95 row: usize,
96 detail_open: bool,
97 detail_scroll: usize,
98 /// Largest useful detail scroll in rendered rows, from the last paint.
99 detail_max_scroll: Cell<usize>,
100 /// Receipt line for the last host action (cancel), shown under the header.
101 status: Option<String>,
102 workspace: PathBuf,
103 owner_session_id: Option<String>,
104 /// The manager follows owner-state changes while open without polling the
105 /// journal on every terminal frame.
106 last_refresh_at: Instant,
107 /// Screen rect of the run list body, recorded at render for mouse parity.
108 list_body: Cell<Rect>,
109 }
110
111 impl WorkflowsManagerView {
112 #[must_use]
113 pub fn new(app: &App) -> Self {
114 let mut view = Self {
115 runs: Vec::new(),
116 row: 0,
117 detail_open: false,
118 detail_scroll: 0,
119 detail_max_scroll: Cell::new(usize::MAX),
120 status: None,
121 workspace: app.workspace.clone(),
122 owner_session_id: app.current_session_id.clone(),
123 last_refresh_at: Instant::now(),
124 list_body: Cell::new(Rect::ZERO),
125 };
126 view.refresh();
127 view
128 }
129
130 /// Re-read the journal (newest first), preserving the selected run id when
131 /// a newer run arrives rather than silently moving focus to a different
132 /// row.
133 fn refresh(&mut self) {
134 let selected_id = self.selected().map(|detail| detail.line.run_id.clone());
135 self.runs = host_workflow_run_details(&self.workspace, self.owner_session_id.as_deref())
136 .into_iter()
137 .rev()
138 .collect();
139 self.row = selected_id
140 .and_then(|run_id| {
141 self.runs
142 .iter()
143 .position(|detail| detail.line.run_id == run_id)
144 })
145 .unwrap_or_else(|| self.row.min(self.runs.len().saturating_sub(1)));
146 self.last_refresh_at = Instant::now();
147 }
148
149 fn selected(&self) -> Option<&HostWorkflowRunDetail> {
150 self.runs.get(self.row)
151 }
152
153 fn move_row(&mut self, delta: isize) {
154 let rows = self.runs.len();
155 if rows == 0 {
156 return;
157 }
158 self.row = wrap_index(self.row, rows, delta);
159 }
160
161 /// Cancel the selected run through the host path — the same one
162 /// `/workflow cancel <run_id>` takes, so receipts and journal state are
163 /// identical. No model turn.
164 fn cancel_selected(&mut self) {
165 let Some(detail) = self.selected() else {
166 return;
167 };
168 if !detail.line.active {
169 self.status = Some(format!(
170 "Run {} already {} — nothing to cancel.",
171 detail.line.run_id, detail.line.status
172 ));
173 return;
174 }
175 let run_id = detail.line.run_id.clone();
176 match host_cancel_workflow(&self.workspace, &run_id, self.owner_session_id.as_deref()) {
177 Ok(line) => {
178 self.status = Some(format!(
179 "Workflow {} {} · {}",
180 line.run_id, line.status, line.label
181 ));
182 }
183 Err(reason) => {
184 self.status = Some(format!("Cancel failed: {reason}"));
185 }
186 }
187 self.refresh();
188 }
189
190 fn footer_hints(&self) -> Vec<ActionHint> {
191 let mut hints = vec![ActionHint::new("↑/↓", "move")];
192 if self.detail_open {
193 hints.push(ActionHint::new("←", "runs"));
194 } else {
195 hints.push(ActionHint::new("Enter", "detail"));
196 }
197 if self.selected().is_some_and(|d| d.line.active) {
198 hints.push(ActionHint::new("x", "cancel"));
199 }
200 hints.push(ActionHint::new("r", "refresh"));
201 hints.push(ActionHint::new("Esc", "close"));
202 hints
203 }
204
205 fn header_lines(&self) -> Vec<Line<'static>> {
206 let active = self.runs.iter().filter(|d| d.line.active).count();
207 let finished = self.runs.len() - active;
208 let mut header = vec![
209 Line::from(vec![
210 Span::styled(
211 "─ Workflow runs ",
212 Style::default().fg(palette::WHALE_ACTION).bold(),
213 ),
214 Span::styled(
215 format!("· {active} active · {finished} finished"),
216 Style::default().fg(palette::TEXT_MUTED),
217 ),
218 ]),
219 Line::from(""),
220 ];
221 if let Some(status) = &self.status {
222 header.push(Line::from(Span::styled(
223 format!(" {status}"),
224 Style::default().fg(palette::WHALE_HUMAN),
225 )));
226 }
227 header
228 }
229
230 fn render_list(&self, area: Rect, buf: &mut Buffer) {
231 self.list_body.set(area);
232 if self.runs.is_empty() {
233 Paragraph::new(Line::from(vec![
234 Span::styled(
235 " No workflow runs in this workspace yet.",
236 Style::default().fg(palette::TEXT_MUTED),
237 ),
238 Span::styled(
239 " /workflow <objective> starts one.",
240 Style::default().fg(palette::TEXT_DIM),
241 ),
242 ]))
243 .render(area, buf);
244 return;
245 }
246
247 let now = now_ms();
248 let rows_visible = usize::from(area.height).max(1);
249 let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
250 for (idx, detail) in self.runs.iter().enumerate() {
251 if idx < scroll || idx >= scroll + rows_visible {
252 continue;
253 }
254 let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX);
255 if y >= area.y + area.height {
256 break;
257 }
258 let selected = idx == self.row;
259 let row_rect = Rect {
260 x: area.x,
261 y,
262 width: area.width,
263 height: 1,
264 };
265 let elapsed = detail
266 .line
267 .completed_at_ms
268 .unwrap_or(now)
269 .saturating_sub(detail.line.started_at_ms)
270 / 1000;
271 let marker = if selected { "▸ " } else { " " };
272 let base = if selected {
273 Style::default().fg(palette::WHALE_ACTION).bold()
274 } else {
275 Style::default().fg(palette::TEXT_SECONDARY)
276 };
277 let mut spans = vec![
278 Span::styled(marker, base),
279 Span::styled(
280 format!("{:<9}", detail.line.status),
281 status_style(detail.line.status).add_modifier(if selected {
282 Modifier::BOLD
283 } else {
284 Modifier::empty()
285 }),
286 ),
287 Span::styled(detail.line.label.clone(), base),
288 Span::styled(
289 format!(
290 " · {} · {} children",
291 crate::elapsed::format_elapsed_secs(elapsed),
292 detail.line.child_count
293 ),
294 Style::default().fg(palette::TEXT_DIM),
295 ),
296 ];
297 if let Some(progress) = detail.line.last_progress.as_deref() {
298 spans.push(Span::styled(
299 format!(" · {progress}"),
300 Style::default().fg(palette::TEXT_DIM),
301 ));
302 }
303 Line::from(spans).render(row_rect, buf);
304 }
305 }
306
307 fn detail_lines(&self, detail: &HostWorkflowRunDetail) -> Vec<Line<'static>> {
308 let now = now_ms();
309 let elapsed = detail
310 .line
311 .completed_at_ms
312 .unwrap_or(now)
313 .saturating_sub(detail.line.started_at_ms)
314 / 1000;
315 let mut lines = vec![
316 Line::from(vec![
317 Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()),
318 Span::styled(
319 detail.line.label.clone(),
320 Style::default().fg(palette::TEXT_PRIMARY).bold(),
321 ),
322 Span::styled(
323 format!(" · {}", detail.line.status),
324 status_style(detail.line.status),
325 ),
326 ]),
327 Line::from(Span::styled(
328 format!(
329 " run {} · {} · {} children",
330 detail.line.run_id,
331 crate::elapsed::format_elapsed_secs(elapsed),
332 detail.line.child_count
333 ),
334 Style::default().fg(palette::TEXT_DIM),
335 )),
336 Line::from(""),
337 ];
338 if !detail.phases.is_empty() {
339 lines.push(Line::from(Span::styled(
340 " Phases",
341 Style::default().fg(palette::TEXT_PRIMARY).bold(),
342 )));
343 lines.push(Line::from(Span::styled(
344 format!(" {}", detail.phases.join(" → ")),
345 Style::default().fg(palette::TEXT_SECONDARY),
346 )));
347 lines.push(Line::from(""));
348 }
349 if !detail.children.is_empty() {
350 lines.push(Line::from(Span::styled(
351 format!(" Children ({})", detail.children.len()),
352 Style::default().fg(palette::TEXT_PRIMARY).bold(),
353 )));
354 for child in &detail.children {
355 lines.push(child_row_line(child, detail.line.active));
356 }
357 lines.push(Line::from(""));
358 }
359 if !detail.progress_tail.is_empty() {
360 lines.push(Line::from(Span::styled(
361 " Recent progress",
362 Style::default().fg(palette::TEXT_PRIMARY).bold(),
363 )));
364 for progress in &detail.progress_tail {
365 lines.push(Line::from(Span::styled(
366 format!(" {progress}"),
367 Style::default().fg(palette::TEXT_SECONDARY),
368 )));
369 }
370 lines.push(Line::from(""));
371 }
372 if let Some(error) = detail.line.error.as_deref() {
373 lines.push(Line::from(vec![
374 Span::styled(" Error ", Style::default().fg(palette::STATUS_ERROR)),
375 Span::styled(
376 error.to_string(),
377 Style::default().fg(palette::TEXT_SECONDARY),
378 ),
379 ]));
380 }
381 lines.push(Line::from(Span::styled(
382 if detail.has_result {
383 " Result retained in the run journal (.codewhale/workflow-runs.jsonl)."
384 } else {
385 " No result recorded yet."
386 },
387 Style::default().fg(palette::TEXT_DIM),
388 )));
389 lines
390 }
391
392 fn render_detail(&self, area: Rect, buf: &mut Buffer) {
393 let Some(detail) = self.selected() else {
394 self.render_list(area, buf);
395 return;
396 };
397 let lines = self.detail_lines(detail);
398 self.detail_max_scroll.set(super::render_wrapped_detail(
399 lines,
400 area,
401 buf,
402 self.detail_scroll,
403 ));
404 }
405 }
406
407 fn child_row_line(child: &HostWorkflowChildRow, run_active: bool) -> Line<'static> {
408 let name = child.label.clone().unwrap_or_else(|| child.task_id.clone());
409 let state = child_display_state(child, run_active);
410 let mut spans = vec![
411 Span::styled(
412 format!(" {} ", child_state_glyph(state)),
413 status_style(state),
414 ),
415 Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)),
416 Span::styled(format!(" · {state}"), status_style(state)),
417 ];
418 let mut meta = Vec::new();
419 if let Some(role) = child.role.as_deref() {
420 meta.push(role.to_string());
421 }
422 if let Some(model) = child.model.as_deref() {
423 meta.push(model.to_string());
424 }
425 if let Some(phase) = child.phase.as_deref() {
426 meta.push(phase.to_string());
427 }
428 if !meta.is_empty() {
429 spans.push(Span::styled(
430 format!(" · {}", meta.join(" · ")),
431 Style::default().fg(palette::TEXT_DIM),
432 ));
433 }
434 Line::from(spans)
435 }
436
437 impl ModalView for WorkflowsManagerView {
438 fn kind(&self) -> ModalKind {
439 ModalKind::WorkflowsManager
440 }
441
442 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
443 self
444 }
445
446 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
447 match key.code {
448 KeyCode::Esc => {
449 if self.detail_open {
450 self.detail_open = false;
451 self.detail_scroll = 0;
452 ViewAction::None
453 } else {
454 ViewAction::Close
455 }
456 }
457 KeyCode::Char('q') => ViewAction::Close,
458 KeyCode::Up | KeyCode::Char('k') => {
459 if self.detail_open {
460 self.detail_scroll = self.detail_scroll.saturating_sub(1);
461 } else {
462 self.move_row(-1);
463 }
464 ViewAction::None
465 }
466 KeyCode::Down | KeyCode::Char('j') => {
467 if self.detail_open {
468 // Stop at the last rendered (wrapped) row of the detail.
469 self.detail_scroll = self
470 .detail_scroll
471 .saturating_add(1)
472 .min(self.detail_max_scroll.get());
473 } else {
474 self.move_row(1);
475 }
476 ViewAction::None
477 }
478 KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
479 if !self.runs.is_empty() {
480 self.detail_open = true;
481 }
482 ViewAction::None
483 }
484 KeyCode::Left | KeyCode::Tab => {
485 self.detail_open = false;
486 self.detail_scroll = 0;
487 ViewAction::None
488 }
489 KeyCode::Char('x') | KeyCode::Char('c') => {
490 self.cancel_selected();
491 ViewAction::None
492 }
493 KeyCode::Char('r') => {
494 self.refresh();
495 ViewAction::None
496 }
497 KeyCode::Char('g') => ViewAction::Close,
498 _ => ViewAction::None,
499 }
500 }
501
502 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
503 if self.detail_open {
504 return ViewAction::None;
505 }
506 // The wheel moves this list, not the transcript behind it.
507 match mouse.kind {
508 MouseEventKind::ScrollUp => {
509 self.move_row(-1);
510 return ViewAction::None;
511 }
512 MouseEventKind::ScrollDown => {
513 self.move_row(1);
514 return ViewAction::None;
515 }
516 _ => {}
517 }
518 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
519 let body = self.list_body.get();
520 if body.width > 0
521 && mouse.row >= body.y
522 && mouse.row < body.y + body.height
523 && mouse.column >= body.x
524 && mouse.column < body.x + body.width
525 {
526 let offset = usize::from(mouse.row - body.y);
527 let scroll = self
528 .row
529 .saturating_sub(usize::from(body.height).saturating_sub(1));
530 let idx = scroll + offset;
531 if idx < self.runs.len() {
532 self.row = idx;
533 }
534 }
535 }
536 ViewAction::None
537 }
538
539 fn tick(&mut self) -> ViewAction {
540 let interval = if self.runs.iter().any(|detail| detail.line.active) {
541 Duration::from_millis(250)
542 } else {
543 Duration::from_secs(2)
544 };
545 if self.last_refresh_at.elapsed() >= interval {
546 self.refresh();
547 }
548 ViewAction::None
549 }
550
551 fn render(&self, area: Rect, buf: &mut Buffer) {
552 Clear.render(area, buf);
553 Block::default()
554 .style(Style::default().bg(palette::WHALE_BG))
555 .render(area, buf);
556
557 let hints = self.footer_hints();
558 let content = render_modal_footer(area, buf, &hints);
559
560 let chunks = Layout::default()
561 .direction(Direction::Vertical)
562 .constraints([Constraint::Length(4), Constraint::Min(1)])
563 .split(content);
564
565 Paragraph::new(self.header_lines())
566 .wrap(Wrap { trim: false })
567 .render(chunks[0], buf);
568
569 if self.detail_open {
570 self.render_detail(chunks[1], buf);
571 } else {
572 self.render_list(chunks[1], buf);
573 }
574 }
575 }
576
577 #[cfg(test)]
578 mod tests {
579 use super::*;
580
581 fn child(state: &'static str) -> HostWorkflowChildRow {
582 HostWorkflowChildRow {
583 task_id: "t1".to_string(),
584 label: None,
585 role: None,
586 model: None,
587 phase: None,
588 state,
589 }
590 }
591
592 /// U09-m3: a child with no completion receipt in a settled run is not
593 /// "running", a cancellation is not a failure, and an unknown state never
594 /// borrows the failure mark.
595 #[test]
596 fn child_rows_keep_their_source_of_truth_state() {
597 assert_eq!(child_display_state(&child("running"), true), "running");
598 assert_eq!(child_display_state(&child("running"), false), "unknown");
599 assert_eq!(child_display_state(&child("succeeded"), false), "succeeded");
600 assert_eq!(child_state_glyph("unknown"), "?");
601 assert_eq!(child_state_glyph("cancelled"), "–");
602 assert_eq!(child_state_glyph("failed"), "✗");
603 assert_eq!(child_state_glyph("budget_exceeded"), "✗");
604 }
605 }
606
606 lines RUST