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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-15 14:04:02 -04:00
parent fd7d302a15
commit a52bdda2ca
3 changed files with 222 additions and 13 deletions

View file

@ -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]

View file

@ -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<fabro_llm::types::Usage> = {
let usages: Vec<fabro_llm::types::Usage> = 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))
}
}

View file

@ -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<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<fabro_llm::types::Usage>,
},
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 {