| 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" | "replay_diverged" => { |
| 55 | Style::default().fg(palette::STATUS_ERROR) |
| 56 | } |
| 57 | _ => Style::default().fg(palette::TEXT_MUTED), |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | fn child_state_glyph(state: &str) -> &'static str { |
| 62 | match state { |
| 63 | "running" | "pending" => "•", |
| 64 | "succeeded" => "✓", |
| 65 | _ => "✗", |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | pub struct WorkflowsManagerView { |
| 70 | /// Newest first, so the live run is the first thing read. |
| 71 | runs: Vec<HostWorkflowRunDetail>, |
| 72 | row: usize, |
| 73 | detail_open: bool, |
| 74 | detail_scroll: usize, |
| 75 | /// Receipt line for the last host action (cancel), shown under the header. |
| 76 | status: Option<String>, |
| 77 | workspace: PathBuf, |
| 78 | owner_session_id: Option<String>, |
| 79 | /// The manager follows owner-state changes while open without polling the |
| 80 | /// journal on every terminal frame. |
| 81 | last_refresh_at: Instant, |
| 82 | /// Screen rect of the run list body, recorded at render for mouse parity. |
| 83 | list_body: Cell<Rect>, |
| 84 | } |
| 85 | |
| 86 | impl WorkflowsManagerView { |
| 87 | #[must_use] |
| 88 | pub fn new(app: &App) -> Self { |
| 89 | let mut view = Self { |
| 90 | runs: Vec::new(), |
| 91 | row: 0, |
| 92 | detail_open: false, |
| 93 | detail_scroll: 0, |
| 94 | status: None, |
| 95 | workspace: app.workspace.clone(), |
| 96 | owner_session_id: app.current_session_id.clone(), |
| 97 | last_refresh_at: Instant::now(), |
| 98 | list_body: Cell::new(Rect::ZERO), |
| 99 | }; |
| 100 | view.refresh(); |
| 101 | view |
| 102 | } |
| 103 | |
| 104 | /// Re-read the journal (newest first), preserving the selected run id when |
| 105 | /// a newer run arrives rather than silently moving focus to a different |
| 106 | /// row. |
| 107 | fn refresh(&mut self) { |
| 108 | let selected_id = self.selected().map(|detail| detail.line.run_id.clone()); |
| 109 | self.runs = host_workflow_run_details(&self.workspace, self.owner_session_id.as_deref()) |
| 110 | .into_iter() |
| 111 | .rev() |
| 112 | .collect(); |
| 113 | self.row = selected_id |
| 114 | .and_then(|run_id| { |
| 115 | self.runs |
| 116 | .iter() |
| 117 | .position(|detail| detail.line.run_id == run_id) |
| 118 | }) |
| 119 | .unwrap_or_else(|| self.row.min(self.runs.len().saturating_sub(1))); |
| 120 | self.last_refresh_at = Instant::now(); |
| 121 | } |
| 122 | |
| 123 | fn selected(&self) -> Option<&HostWorkflowRunDetail> { |
| 124 | self.runs.get(self.row) |
| 125 | } |
| 126 | |
| 127 | fn move_row(&mut self, delta: isize) { |
| 128 | let rows = self.runs.len(); |
| 129 | if rows == 0 { |
| 130 | return; |
| 131 | } |
| 132 | self.row = wrap_index(self.row, rows, delta); |
| 133 | } |
| 134 | |
| 135 | /// Cancel the selected run through the host path — the same one |
| 136 | /// `/workflow cancel <run_id>` takes, so receipts and journal state are |
| 137 | /// identical. No model turn. |
| 138 | fn cancel_selected(&mut self) { |
| 139 | let Some(detail) = self.selected() else { |
| 140 | return; |
| 141 | }; |
| 142 | if !detail.line.active { |
| 143 | self.status = Some(format!( |
| 144 | "Run {} already {} — nothing to cancel.", |
| 145 | detail.line.run_id, detail.line.status |
| 146 | )); |
| 147 | return; |
| 148 | } |
| 149 | let run_id = detail.line.run_id.clone(); |
| 150 | match host_cancel_workflow(&self.workspace, &run_id, self.owner_session_id.as_deref()) { |
| 151 | Ok(line) => { |
| 152 | self.status = Some(format!( |
| 153 | "Workflow {} {} · {}", |
| 154 | line.run_id, line.status, line.label |
| 155 | )); |
| 156 | } |
| 157 | Err(reason) => { |
| 158 | self.status = Some(format!("Cancel failed: {reason}")); |
| 159 | } |
| 160 | } |
| 161 | self.refresh(); |
| 162 | } |
| 163 | |
| 164 | fn footer_hints(&self) -> Vec<ActionHint> { |
| 165 | let mut hints = vec![ActionHint::new("↑/↓", "move")]; |
| 166 | if self.detail_open { |
| 167 | hints.push(ActionHint::new("←", "runs")); |
| 168 | } else { |
| 169 | hints.push(ActionHint::new("Enter", "detail")); |
| 170 | } |
| 171 | if self.selected().is_some_and(|d| d.line.active) { |
| 172 | hints.push(ActionHint::new("x", "cancel")); |
| 173 | } |
| 174 | hints.push(ActionHint::new("r", "refresh")); |
| 175 | hints.push(ActionHint::new("Esc", "close")); |
| 176 | hints |
| 177 | } |
| 178 | |
| 179 | fn header_lines(&self) -> Vec<Line<'static>> { |
| 180 | let active = self.runs.iter().filter(|d| d.line.active).count(); |
| 181 | let finished = self.runs.len() - active; |
| 182 | let mut header = vec![ |
| 183 | Line::from(vec![ |
| 184 | Span::styled( |
| 185 | "─ Workflow runs ", |
| 186 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 187 | ), |
| 188 | Span::styled( |
| 189 | format!("· {active} active · {finished} finished"), |
| 190 | Style::default().fg(palette::TEXT_MUTED), |
| 191 | ), |
| 192 | ]), |
| 193 | Line::from(""), |
| 194 | ]; |
| 195 | if let Some(status) = &self.status { |
| 196 | header.push(Line::from(Span::styled( |
| 197 | format!(" {status}"), |
| 198 | Style::default().fg(palette::WHALE_HUMAN), |
| 199 | ))); |
| 200 | } |
| 201 | header |
| 202 | } |
| 203 | |
| 204 | fn render_list(&self, area: Rect, buf: &mut Buffer) { |
| 205 | self.list_body.set(area); |
| 206 | if self.runs.is_empty() { |
| 207 | Paragraph::new(Line::from(vec![ |
| 208 | Span::styled( |
| 209 | " No workflow runs in this workspace yet.", |
| 210 | Style::default().fg(palette::TEXT_MUTED), |
| 211 | ), |
| 212 | Span::styled( |
| 213 | " /workflow <objective> starts one.", |
| 214 | Style::default().fg(palette::TEXT_DIM), |
| 215 | ), |
| 216 | ])) |
| 217 | .render(area, buf); |
| 218 | return; |
| 219 | } |
| 220 | |
| 221 | let now = now_ms(); |
| 222 | let rows_visible = usize::from(area.height).max(1); |
| 223 | let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1)); |
| 224 | for (idx, detail) in self.runs.iter().enumerate() { |
| 225 | if idx < scroll || idx >= scroll + rows_visible { |
| 226 | continue; |
| 227 | } |
| 228 | let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX); |
| 229 | if y >= area.y + area.height { |
| 230 | break; |
| 231 | } |
| 232 | let selected = idx == self.row; |
| 233 | let row_rect = Rect { |
| 234 | x: area.x, |
| 235 | y, |
| 236 | width: area.width, |
| 237 | height: 1, |
| 238 | }; |
| 239 | let elapsed = detail |
| 240 | .line |
| 241 | .completed_at_ms |
| 242 | .unwrap_or(now) |
| 243 | .saturating_sub(detail.line.started_at_ms) |
| 244 | / 1000; |
| 245 | let marker = if selected { "▸ " } else { " " }; |
| 246 | let base = if selected { |
| 247 | Style::default().fg(palette::WHALE_ACTION).bold() |
| 248 | } else { |
| 249 | Style::default().fg(palette::TEXT_SECONDARY) |
| 250 | }; |
| 251 | let mut spans = vec![ |
| 252 | Span::styled(marker, base), |
| 253 | Span::styled( |
| 254 | format!("{:<9}", detail.line.status), |
| 255 | status_style(detail.line.status).add_modifier(if selected { |
| 256 | Modifier::BOLD |
| 257 | } else { |
| 258 | Modifier::empty() |
| 259 | }), |
| 260 | ), |
| 261 | Span::styled(detail.line.label.clone(), base), |
| 262 | Span::styled( |
| 263 | format!( |
| 264 | " · {} · {} children", |
| 265 | crate::elapsed::format_elapsed_secs(elapsed), |
| 266 | detail.line.child_count |
| 267 | ), |
| 268 | Style::default().fg(palette::TEXT_DIM), |
| 269 | ), |
| 270 | ]; |
| 271 | if let Some(progress) = detail.line.last_progress.as_deref() { |
| 272 | spans.push(Span::styled( |
| 273 | format!(" · {progress}"), |
| 274 | Style::default().fg(palette::TEXT_DIM), |
| 275 | )); |
| 276 | } |
| 277 | Line::from(spans).render(row_rect, buf); |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | fn detail_lines(&self, detail: &HostWorkflowRunDetail) -> Vec<Line<'static>> { |
| 282 | let now = now_ms(); |
| 283 | let elapsed = detail |
| 284 | .line |
| 285 | .completed_at_ms |
| 286 | .unwrap_or(now) |
| 287 | .saturating_sub(detail.line.started_at_ms) |
| 288 | / 1000; |
| 289 | let mut lines = vec![ |
| 290 | Line::from(vec![ |
| 291 | Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()), |
| 292 | Span::styled( |
| 293 | detail.line.label.clone(), |
| 294 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 295 | ), |
| 296 | Span::styled( |
| 297 | format!(" · {}", detail.line.status), |
| 298 | status_style(detail.line.status), |
| 299 | ), |
| 300 | ]), |
| 301 | Line::from(Span::styled( |
| 302 | format!( |
| 303 | " run {} · {} · {} children", |
| 304 | detail.line.run_id, |
| 305 | crate::elapsed::format_elapsed_secs(elapsed), |
| 306 | detail.line.child_count |
| 307 | ), |
| 308 | Style::default().fg(palette::TEXT_DIM), |
| 309 | )), |
| 310 | Line::from(""), |
| 311 | ]; |
| 312 | if !detail.phases.is_empty() { |
| 313 | lines.push(Line::from(Span::styled( |
| 314 | " Phases", |
| 315 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 316 | ))); |
| 317 | lines.push(Line::from(Span::styled( |
| 318 | format!(" {}", detail.phases.join(" → ")), |
| 319 | Style::default().fg(palette::TEXT_SECONDARY), |
| 320 | ))); |
| 321 | lines.push(Line::from("")); |
| 322 | } |
| 323 | if !detail.children.is_empty() { |
| 324 | lines.push(Line::from(Span::styled( |
| 325 | format!(" Children ({})", detail.children.len()), |
| 326 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 327 | ))); |
| 328 | for child in &detail.children { |
| 329 | lines.push(child_row_line(child)); |
| 330 | } |
| 331 | lines.push(Line::from("")); |
| 332 | } |
| 333 | if !detail.progress_tail.is_empty() { |
| 334 | lines.push(Line::from(Span::styled( |
| 335 | " Recent progress", |
| 336 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 337 | ))); |
| 338 | for progress in &detail.progress_tail { |
| 339 | lines.push(Line::from(Span::styled( |
| 340 | format!(" {progress}"), |
| 341 | Style::default().fg(palette::TEXT_SECONDARY), |
| 342 | ))); |
| 343 | } |
| 344 | lines.push(Line::from("")); |
| 345 | } |
| 346 | if let Some(error) = detail.line.error.as_deref() { |
| 347 | lines.push(Line::from(vec![ |
| 348 | Span::styled(" Error ", Style::default().fg(palette::STATUS_ERROR)), |
| 349 | Span::styled( |
| 350 | error.to_string(), |
| 351 | Style::default().fg(palette::TEXT_SECONDARY), |
| 352 | ), |
| 353 | ])); |
| 354 | } |
| 355 | lines.push(Line::from(Span::styled( |
| 356 | if detail.has_result { |
| 357 | " Result retained in the run journal (.codewhale/workflow-runs.jsonl)." |
| 358 | } else { |
| 359 | " No result recorded yet." |
| 360 | }, |
| 361 | Style::default().fg(palette::TEXT_DIM), |
| 362 | ))); |
| 363 | lines |
| 364 | } |
| 365 | |
| 366 | fn render_detail(&self, area: Rect, buf: &mut Buffer) { |
| 367 | let Some(detail) = self.selected() else { |
| 368 | self.render_list(area, buf); |
| 369 | return; |
| 370 | }; |
| 371 | let lines = self.detail_lines(detail); |
| 372 | let visible = usize::from(area.height).max(1); |
| 373 | let max_scroll = lines.len().saturating_sub(visible); |
| 374 | let scroll = self.detail_scroll.min(max_scroll); |
| 375 | Paragraph::new(lines.iter().skip(scroll).cloned().collect::<Vec<_>>()) |
| 376 | .wrap(Wrap { trim: false }) |
| 377 | .render(area, buf); |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | fn child_row_line(child: &HostWorkflowChildRow) -> Line<'static> { |
| 382 | let name = child.label.clone().unwrap_or_else(|| child.task_id.clone()); |
| 383 | let mut spans = vec![ |
| 384 | Span::styled( |
| 385 | format!(" {} ", child_state_glyph(child.state)), |
| 386 | status_style(child.state), |
| 387 | ), |
| 388 | Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)), |
| 389 | Span::styled(format!(" · {}", child.state), status_style(child.state)), |
| 390 | ]; |
| 391 | let mut meta = Vec::new(); |
| 392 | if let Some(role) = child.role.as_deref() { |
| 393 | meta.push(role.to_string()); |
| 394 | } |
| 395 | if let Some(model) = child.model.as_deref() { |
| 396 | meta.push(model.to_string()); |
| 397 | } |
| 398 | if let Some(phase) = child.phase.as_deref() { |
| 399 | meta.push(phase.to_string()); |
| 400 | } |
| 401 | if !meta.is_empty() { |
| 402 | spans.push(Span::styled( |
| 403 | format!(" · {}", meta.join(" · ")), |
| 404 | Style::default().fg(palette::TEXT_DIM), |
| 405 | )); |
| 406 | } |
| 407 | Line::from(spans) |
| 408 | } |
| 409 | |
| 410 | impl ModalView for WorkflowsManagerView { |
| 411 | fn kind(&self) -> ModalKind { |
| 412 | ModalKind::WorkflowsManager |
| 413 | } |
| 414 | |
| 415 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 416 | self |
| 417 | } |
| 418 | |
| 419 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 420 | match key.code { |
| 421 | KeyCode::Esc => { |
| 422 | if self.detail_open { |
| 423 | self.detail_open = false; |
| 424 | self.detail_scroll = 0; |
| 425 | ViewAction::None |
| 426 | } else { |
| 427 | ViewAction::Close |
| 428 | } |
| 429 | } |
| 430 | KeyCode::Char('q') => ViewAction::Close, |
| 431 | KeyCode::Up | KeyCode::Char('k') => { |
| 432 | if self.detail_open { |
| 433 | self.detail_scroll = self.detail_scroll.saturating_sub(1); |
| 434 | } else { |
| 435 | self.move_row(-1); |
| 436 | } |
| 437 | ViewAction::None |
| 438 | } |
| 439 | KeyCode::Down | KeyCode::Char('j') => { |
| 440 | if self.detail_open { |
| 441 | // The render path clamps to the last scrollable line. |
| 442 | self.detail_scroll = self.detail_scroll.saturating_add(1); |
| 443 | } else { |
| 444 | self.move_row(1); |
| 445 | } |
| 446 | ViewAction::None |
| 447 | } |
| 448 | KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { |
| 449 | if !self.runs.is_empty() { |
| 450 | self.detail_open = true; |
| 451 | } |
| 452 | ViewAction::None |
| 453 | } |
| 454 | KeyCode::Left | KeyCode::Tab => { |
| 455 | self.detail_open = false; |
| 456 | self.detail_scroll = 0; |
| 457 | ViewAction::None |
| 458 | } |
| 459 | KeyCode::Char('x') | KeyCode::Char('c') => { |
| 460 | self.cancel_selected(); |
| 461 | ViewAction::None |
| 462 | } |
| 463 | KeyCode::Char('r') => { |
| 464 | self.refresh(); |
| 465 | ViewAction::None |
| 466 | } |
| 467 | KeyCode::Char('g') => ViewAction::Close, |
| 468 | _ => ViewAction::None, |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 473 | if self.detail_open { |
| 474 | return ViewAction::None; |
| 475 | } |
| 476 | // The wheel moves this list, not the transcript behind it. |
| 477 | match mouse.kind { |
| 478 | MouseEventKind::ScrollUp => { |
| 479 | self.move_row(-1); |
| 480 | return ViewAction::None; |
| 481 | } |
| 482 | MouseEventKind::ScrollDown => { |
| 483 | self.move_row(1); |
| 484 | return ViewAction::None; |
| 485 | } |
| 486 | _ => {} |
| 487 | } |
| 488 | if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { |
| 489 | let body = self.list_body.get(); |
| 490 | if body.width > 0 |
| 491 | && mouse.row >= body.y |
| 492 | && mouse.row < body.y + body.height |
| 493 | && mouse.column >= body.x |
| 494 | && mouse.column < body.x + body.width |
| 495 | { |
| 496 | let offset = usize::from(mouse.row - body.y); |
| 497 | let scroll = self |
| 498 | .row |
| 499 | .saturating_sub(usize::from(body.height).saturating_sub(1)); |
| 500 | let idx = scroll + offset; |
| 501 | if idx < self.runs.len() { |
| 502 | self.row = idx; |
| 503 | } |
| 504 | } |
| 505 | } |
| 506 | ViewAction::None |
| 507 | } |
| 508 | |
| 509 | fn tick(&mut self) -> ViewAction { |
| 510 | let interval = if self.runs.iter().any(|detail| detail.line.active) { |
| 511 | Duration::from_millis(250) |
| 512 | } else { |
| 513 | Duration::from_secs(2) |
| 514 | }; |
| 515 | if self.last_refresh_at.elapsed() >= interval { |
| 516 | self.refresh(); |
| 517 | } |
| 518 | ViewAction::None |
| 519 | } |
| 520 | |
| 521 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 522 | Clear.render(area, buf); |
| 523 | Block::default() |
| 524 | .style(Style::default().bg(palette::WHALE_BG)) |
| 525 | .render(area, buf); |
| 526 | |
| 527 | let hints = self.footer_hints(); |
| 528 | let content = render_modal_footer(area, buf, &hints); |
| 529 | |
| 530 | let chunks = Layout::default() |
| 531 | .direction(Direction::Vertical) |
| 532 | .constraints([Constraint::Length(4), Constraint::Min(1)]) |
| 533 | .split(content); |
| 534 | |
| 535 | Paragraph::new(self.header_lines()) |
| 536 | .wrap(Wrap { trim: false }) |
| 537 | .render(chunks[0], buf); |
| 538 | |
| 539 | if self.detail_open { |
| 540 | self.render_detail(chunks[1], buf); |
| 541 | } else { |
| 542 | self.render_list(chunks[1], buf); |
| 543 | } |
| 544 | } |
| 545 | } |
| 546 |