From a52bdda2cacb6abb1e374aaefae68a8ea27a28d6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 15 Mar 2026 14:04:02 -0400 Subject: [PATCH] Add status + usage to WorkflowRunCompleted, enrich `fabro logs --pretty` summary Adds aggregate `status` and `usage` fields to the WorkflowRunCompleted event so `fabro logs --pretty` can render a complete end-of-run summary (status, tokens, cache, reasoning) without scanning all StageCompleted events. Also adds pretty handlers for PullRequestCreated/Failed events. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-workflows/src/cli/logs.rs | 144 ++++++++++++++++++++- lib/crates/fabro-workflows/src/engine.rs | 36 +++++- lib/crates/fabro-workflows/src/event.rs | 55 +++++++- 3 files changed, 222 insertions(+), 13 deletions(-) diff --git a/lib/crates/fabro-workflows/src/cli/logs.rs b/lib/crates/fabro-workflows/src/cli/logs.rs index e3e7aaa30..640ed5cc3 100644 --- a/lib/crates/fabro-workflows/src/cli/logs.rs +++ b/lib/crates/fabro-workflows/src/cli/logs.rs @@ -214,15 +214,66 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> "WorkflowRunCompleted" => { let duration = format_duration_ms(envelope.get("duration_ms")); + let status_str = str_field(&envelope, "status").unwrap_or("success"); + let status_upper = status_str.to_uppercase(); + let status_style = match status_str { + "success" | "partial_success" => &styles.bold_green, + _ => &styles.bold_red, + }; let cost = format_cost(envelope.get("total_cost")); - Some(format!( - "{} {} {} {} {}", + + let mut lines = vec![format!( + "{} {} {} {}", styles.dim.apply_to(&ts), - styles.bold_green.apply_to("\u{2713} Completed"), + status_style.apply_to(format!("\u{2713} {status_upper}")), styles.bold.apply_to(&duration), styles.dim.apply_to(&cost), - "", - )) + )]; + + if let Some(usage) = envelope.get("usage") { + let total = usage + .get("total_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let pad = " ".repeat(ts.len() + 1); + if total > 0 { + lines.push(format!( + "{}{}", + pad, + styles + .dim + .apply_to(format!("Tokens: {}", format_tokens(total as u64))) + )); + } + if let Some(cr) = usage.get("cache_read_tokens").and_then(|v| v.as_i64()) { + let cw = usage + .get("cache_write_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + lines.push(format!( + "{}{}", + pad, + styles.dim.apply_to(format!( + "Cache: {} read, {} write", + format_tokens(cr as u64), + format_tokens(cw as u64) + )) + )); + } + if let Some(r) = usage.get("reasoning_tokens").and_then(|v| v.as_i64()) { + if r > 0 { + lines.push(format!( + "{}{}", + pad, + styles + .dim + .apply_to(format!("Reasoning: {} tokens", format_tokens(r as u64))) + )); + } + } + } + + Some(lines.join("\n")) } "WorkflowRunFailed" => { @@ -447,6 +498,31 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> )) } + "PullRequestCreated" => { + let url = str_field(&envelope, "pr_url").unwrap_or("?"); + let draft = envelope + .get("draft") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let label = if draft { "Draft PR:" } else { "PR:" }; + Some(format!( + "{} {} {}", + styles.dim.apply_to(&ts), + styles.bold.apply_to(label), + url, + )) + } + + "PullRequestFailed" => { + let error = str_field(&envelope, "error").unwrap_or("unknown error"); + Some(format!( + "{} {} {}", + styles.dim.apply_to(&ts), + styles.bold_red.apply_to("PR failed:"), + styles.red.apply_to(error), + )) + } + // Noise events — skip "Agent.SessionStarted" | "Agent.SessionEnded" @@ -705,11 +781,65 @@ mod tests { #[test] fn pretty_workflow_run_completed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"total_cost":0.57}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}"#; let result = format_event_pretty(line, &styles).unwrap(); - assert!(result.contains("Completed"), "got: {result}"); + assert!(result.contains("SUCCESS"), "got: {result}"); assert!(result.contains("25s"), "got: {result}"); assert!(result.contains("$0.57"), "got: {result}"); + assert!(result.contains("7.0k toks"), "got: {result}"); + assert!(result.contains("Cache:"), "got: {result}"); + assert!(result.contains("3.0k toks read"), "got: {result}"); + assert!(result.contains("Reasoning:"), "got: {result}"); + } + + #[test] + fn pretty_workflow_run_completed_backward_compat() { + let styles = no_color_styles(); + // Old JSONL without status/usage still renders + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"total_cost":0.57}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("SUCCESS"), "got: {result}"); + assert!(result.contains("25s"), "got: {result}"); + assert!(result.contains("$0.57"), "got: {result}"); + // No usage lines when usage is absent + assert!(!result.contains("Tokens:"), "got: {result}"); + } + + #[test] + fn pretty_workflow_run_completed_fail_status() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"WorkflowRunCompleted","duration_ms":25000,"status":"fail"}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("FAIL"), "got: {result}"); + } + + #[test] + fn pretty_pull_request_created() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestCreated","pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":false}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("PR:"), "got: {result}"); + assert!( + result.contains("https://github.com/owner/repo/pull/42"), + "got: {result}" + ); + } + + #[test] + fn pretty_pull_request_created_draft() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestCreated","pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":true}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("Draft PR:"), "got: {result}"); + } + + #[test] + fn pretty_pull_request_failed() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestFailed","error":"auth token expired"}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("PR failed:"), "got: {result}"); + assert!(result.contains("auth token expired"), "got: {result}"); } #[test] diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index 278e31f29..1c1b40dcd 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -2138,13 +2138,44 @@ impl WorkflowRunEngine { None } }; + + let last_outcome = node_outcomes + .get(completed_nodes.last().unwrap_or(&String::new())) + .cloned() + .unwrap_or_else(Outcome::success); + + let run_usage: Option = { + let usages: Vec = node_outcomes + .values() + .filter_map(|o| { + let u = o.usage.as_ref()?; + Some(fabro_llm::types::Usage { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + total_tokens: u.input_tokens + u.output_tokens, + cache_read_tokens: u.cache_read_tokens, + cache_write_tokens: u.cache_write_tokens, + reasoning_tokens: u.reasoning_tokens, + raw: None, + }) + }) + .collect(); + if usages.is_empty() { + None + } else { + Some(usages.into_iter().reduce(|a, b| a + b).unwrap()) + } + }; + self.services .emitter .emit(&WorkflowRunEvent::WorkflowRunCompleted { duration_ms, artifact_count: artifact_store.list().len(), + status: last_outcome.status.to_string(), total_cost, final_git_commit_sha: last_git_sha.clone(), + usage: run_usage, }); // RunComplete hook (non-blocking) @@ -2166,11 +2197,6 @@ impl WorkflowRunEngine { } } - // Return last outcome, or success if no outcomes recorded - let last_outcome = node_outcomes - .get(completed_nodes.last().unwrap_or(&String::new())) - .cloned() - .unwrap_or_else(Outcome::success); Ok((last_outcome, context)) } } diff --git a/lib/crates/fabro-workflows/src/event.rs b/lib/crates/fabro-workflows/src/event.rs index 13d01a3e3..05ce77702 100644 --- a/lib/crates/fabro-workflows/src/event.rs +++ b/lib/crates/fabro-workflows/src/event.rs @@ -21,10 +21,14 @@ pub enum WorkflowRunEvent { WorkflowRunCompleted { duration_ms: u64, artifact_count: usize, + #[serde(default)] + status: String, #[serde(default, skip_serializing_if = "Option::is_none")] total_cost: Option, #[serde(default, skip_serializing_if = "Option::is_none")] final_git_commit_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, }, WorkflowRunFailed { error: crate::error::FabroError, @@ -284,9 +288,13 @@ impl WorkflowRunEvent { Self::WorkflowRunCompleted { duration_ms, artifact_count, + status, .. } => { - info!(duration_ms, artifact_count, "Workflow run completed"); + info!( + duration_ms, + artifact_count, status, "Workflow run completed" + ); } Self::WorkflowRunFailed { error, duration_ms, .. @@ -1970,6 +1978,51 @@ mod tests { ); } + #[test] + fn workflow_run_completed_serialization_with_status_and_usage() { + let event = WorkflowRunEvent::WorkflowRunCompleted { + duration_ms: 30000, + artifact_count: 2, + status: "success".to_string(), + total_cost: Some(1.23), + final_git_commit_sha: Some("abc123".to_string()), + usage: Some(Usage { + input_tokens: 5000, + output_tokens: 2000, + total_tokens: 7000, + cache_read_tokens: Some(3000), + cache_write_tokens: Some(500), + reasoning_tokens: Some(800), + raw: None, + }), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"status\":\"success\"")); + assert!(json.contains("\"total_tokens\":7000")); + assert!(json.contains("\"cache_read_tokens\":3000")); + assert!(json.contains("\"reasoning_tokens\":800")); + + let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); + assert!(matches!( + deserialized, + WorkflowRunEvent::WorkflowRunCompleted { status, usage: Some(u), .. } + if status == "success" && u.total_tokens == 7000 + )); + } + + #[test] + fn workflow_run_completed_backward_compat_without_new_fields() { + // Old JSONL without status/usage should deserialize with defaults + let json = + r#"{"WorkflowRunCompleted":{"duration_ms":5000,"artifact_count":1,"total_cost":0.25}}"#; + let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); + assert!(matches!( + deserialized, + WorkflowRunEvent::WorkflowRunCompleted { status, usage, .. } + if status.is_empty() && usage.is_none() + )); + } + #[test] fn devcontainer_resolved_serializes() { let event = WorkflowRunEvent::DevcontainerResolved {