mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
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:
parent
fd7d302a15
commit
a52bdda2ca
3 changed files with 222 additions and 13 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue