From ed8d88441befdd9df1b9f9a24d851b8697c8bc41 Mon Sep 17 00:00:00 2001 From: Fabro Date: Sun, 15 Mar 2026 19:07:21 +0000 Subject: [PATCH] fabro(01KKSDEQC3GZ6NB715GFVPPRG6): implement (success) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KKSDEQC3GZ6NB715GFVPPRG6 Fabro-Completed: 5 Fabro-Checkpoint: d4b68fa788f71957ad4d8e317bca45ba21ded50e ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/crates/fabro-workflows/src/cli/logs.rs | 39 ++++++++++++++++- lib/crates/fabro-workflows/src/engine.rs | 8 ++++ lib/crates/fabro-workflows/src/event.rs | 51 ++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-workflows/src/cli/logs.rs b/lib/crates/fabro-workflows/src/cli/logs.rs index c9d3164e8..110833043 100644 --- a/lib/crates/fabro-workflows/src/cli/logs.rs +++ b/lib/crates/fabro-workflows/src/cli/logs.rs @@ -203,13 +203,28 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> "WorkflowRunStarted" => { let name = str_field(&envelope, "workflow_name").unwrap_or("?"); let run_id = str_field(&envelope, "run_id").unwrap_or("?"); - Some(format!( + let header = format!( "{} {} {} {}", styles.dim.apply_to(&ts), styles.bold_cyan.apply_to("\u{25b6}"), styles.bold.apply_to(name), styles.dim.apply_to(run_id), - )) + ); + match str_field(&envelope, "goal") { + Some(goal) if !goal.is_empty() => { + let indent = " "; + let term_width = fabro_util::terminal::Styles::terminal_width(); + let wrap_width = term_width.saturating_sub(indent.len()); + let rendered = styles.render_markdown_width(goal, wrap_width); + let body: String = rendered + .lines() + .map(|l| format!("{indent}{l}")) + .collect::>() + .join("\n"); + Some(format!("{header}\n{body}\n")) + } + _ => Some(header), + } } "WorkflowRunCompleted" => { @@ -700,6 +715,26 @@ mod tests { let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("smoke"), "got: {result}"); assert!(result.contains("abc123"), "got: {result}"); + // No goal — should be a single line (no newline-separated body) + assert!( + !result.contains('\n'), + "no goal means single line, got: {result}" + ); + } + + #[test] + fn pretty_workflow_run_started_with_goal() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke","goal":"Fix the bug"}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("smoke"), "got: {result}"); + assert!(result.contains("abc123"), "got: {result}"); + assert!(result.contains("Fix the bug"), "got: {result}"); + // Goal should be on a separate indented line + assert!( + result.contains('\n'), + "goal should produce multi-line output, got: {result}" + ); } #[test] diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index 278e31f29..ccc965ce1 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -1216,6 +1216,14 @@ impl WorkflowRunEngine { } else { None }, + goal: { + let g = graph.goal(); + if g.is_empty() { + None + } else { + Some(g.to_string()) + } + }, }); // Resolve work_dir from config for hooks diff --git a/lib/crates/fabro-workflows/src/event.rs b/lib/crates/fabro-workflows/src/event.rs index 13d01a3e3..1adac874d 100644 --- a/lib/crates/fabro-workflows/src/event.rs +++ b/lib/crates/fabro-workflows/src/event.rs @@ -17,6 +17,8 @@ pub enum WorkflowRunEvent { run_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] worktree_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + goal: Option, }, WorkflowRunCompleted { duration_ms: u64, @@ -1013,6 +1015,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }); let events = received.lock().unwrap(); assert_eq!(events.len(), 1); @@ -1510,6 +1513,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }); assert!(emitter.last_event_at() > 0); } @@ -1679,6 +1683,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }; let (name, fields) = flatten_event(&event); assert_eq!(name, "WorkflowRunStarted"); @@ -2060,4 +2065,50 @@ mod tests { assert_eq!(fields["command_index"], 1); assert!(!fields.contains_key("index")); } + + #[test] + fn workflow_run_started_with_goal_roundtrip() { + let event = WorkflowRunEvent::WorkflowRunStarted { + name: "deploy".to_string(), + run_id: "r42".to_string(), + base_sha: None, + run_branch: None, + worktree_dir: None, + goal: Some("Fix the bug".to_string()), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"goal\":\"Fix the bug\"")); + let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); + assert!( + matches!(deserialized, WorkflowRunEvent::WorkflowRunStarted { goal: Some(g), .. } if g == "Fix the bug") + ); + } + + #[test] + fn workflow_run_started_without_goal_backward_compat() { + // Old JSONL without `goal` field should deserialize with goal: None + let json = r#"{"WorkflowRunStarted":{"name":"smoke","run_id":"r1"}}"#; + let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); + assert!(matches!( + deserialized, + WorkflowRunEvent::WorkflowRunStarted { goal: None, .. } + )); + } + + #[test] + fn workflow_run_started_none_goal_omitted_from_json() { + let event = WorkflowRunEvent::WorkflowRunStarted { + name: "ci".to_string(), + run_id: "r2".to_string(), + base_sha: None, + run_branch: None, + worktree_dir: None, + goal: None, + }; + let json = serde_json::to_string(&event).unwrap(); + assert!( + !json.contains("goal"), + "goal should be omitted when None: {json}" + ); + } }