From 70ea9f458d42c97dd80d2be4a7e677d7ed2d6789 Mon Sep 17 00:00:00 2001 From: "arc-1e68f1[bot]" <265161896+arc-1e68f1[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:46:17 -0400 Subject: [PATCH] Add `goal` to WorkflowRunStarted and render in `fabro logs --pretty` (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a `goal` field to the `WorkflowRunStarted` event so that users can immediately see what a workflow is trying to accomplish when reading logs. The field is an `Option` with `serde(default, skip_serializing_if)` to maintain backward compatibility with existing JSONL logs that don't include it—mirroring the same pattern used by `base_sha` and `run_branch`. On the rendering side, `fabro logs --pretty` now displays the goal below the workflow header line when present, using markdown rendering with proper indentation and terminal-width wrapping. The markdown rendering logic was extracted into a shared `render_indented_markdown` helper, which is also now used by the existing `AssistantMessage` rendering to eliminate duplication. Tests cover round-trip serialization with a goal, backward-compatible deserialization of old events without the field, verification that `None` goals are omitted from JSON output, and pretty-formatting behavior both with and without a goal present. ### Fabro Details
Ran 7 stages in 23m 38s for $3.02 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 0s | – | 0 | | preflight_compile | 0s | – | 0 | | preflight_lint | 0s | – | 0 | | implement | 0s | $1.76 | 0 | | simplify | 0s | $1.26 | 0 | | verify | 0s | – | 0 | | **Total** | **23m 38s** | **$3.02** | **0** |
Ran ImplementAndSimplify.fabro (10 nodes and 13 edges) ```dot digraph ImplementAndSimplify { graph [ goal="Implement and simplify", model_stylesheet=" * { backend: api; model: claude-opus-4-6;} " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."] simplify [label="Simplify", prompt="@prompts/simplify.md"] verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=success"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=success"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=success"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify -> verify verify -> exit [condition="outcome=success"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro Co-authored-by: Bryan Helmkamp Co-authored-by: Claude Opus 4.6 (1M context) --- lib/crates/fabro-workflows/src/cli/logs.rs | 58 ++++++++++++++++++---- lib/crates/fabro-workflows/src/engine.rs | 1 + lib/crates/fabro-workflows/src/event.rs | 51 +++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/lib/crates/fabro-workflows/src/cli/logs.rs b/lib/crates/fabro-workflows/src/cli/logs.rs index 084209d06..3f6bd6324 100644 --- a/lib/crates/fabro-workflows/src/cli/logs.rs +++ b/lib/crates/fabro-workflows/src/cli/logs.rs @@ -194,6 +194,22 @@ fn follow_logs( // ── Pretty formatter ────────────────────────────────────────────────── +/// Render markdown text with indentation, wrapping to terminal width. +fn render_indented_markdown( + styles: &fabro_util::terminal::Styles, + text: &str, + indent: &str, +) -> String { + let term_width = fabro_util::terminal::Styles::terminal_width(); + let wrap_width = term_width.saturating_sub(indent.len()); + let rendered = styles.render_markdown_width(text, wrap_width); + rendered + .lines() + .map(|l| format!("{indent}{l}")) + .collect::>() + .join("\n") +} + pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> Option { let envelope: serde_json::Value = serde_json::from_str(line).ok()?; let event = envelope.get("event")?.as_str()?; @@ -203,13 +219,20 @@ 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 body = render_indented_markdown(styles, goal, " "); + Some(format!("{header}\n{body}\n")) + } + _ => Some(header), + } } "WorkflowRunCompleted" => { @@ -349,15 +372,7 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> styles.dim.apply_to(model), styles.dim.apply_to("]"), ); - 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(text, wrap_width); - let body: String = rendered - .lines() - .map(|l| format!("{indent}{l}")) - .collect::>() - .join("\n"); + let body = render_indented_markdown(styles, text, " "); Some(format!("{header}\n{body}\n")) } @@ -816,6 +831,27 @@ mod tests { assert!(result.contains("abc123"), "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}"); + // Should be multi-line (header + body) + assert!(result.contains('\n'), "got: {result}"); + } + + #[test] + fn pretty_workflow_run_started_without_goal_no_extra_lines() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke"}"#; + let result = format_event_pretty(line, &styles).unwrap(); + // Without goal, should be a single line (no newlines) + assert!(!result.contains('\n'), "got: {result}"); + } + #[test] fn pretty_workflow_run_completed() { let styles = no_color_styles(); diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index 47e7da216..d9a71b8f6 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -1229,6 +1229,7 @@ impl WorkflowRunEngine { } else { None }, + goal: (!graph.goal().is_empty()).then(|| graph.goal().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 449216cb0..85a579792 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, @@ -1091,6 +1093,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }); let events = received.lock().unwrap(); assert_eq!(events.len(), 1); @@ -1588,6 +1591,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }); assert!(emitter.last_event_at() > 0); } @@ -1757,6 +1761,7 @@ mod tests { base_sha: None, run_branch: None, worktree_dir: None, + goal: None, }; let (name, fields) = flatten_event(&event); assert_eq!(name, "WorkflowRunStarted"); @@ -2333,6 +2338,52 @@ mod tests { assert!(!fields.contains_key("index")); } + #[test] + fn workflow_run_started_with_goal_round_trip() { + let event = WorkflowRunEvent::WorkflowRunStarted { + name: "my_workflow".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 to `goal: None` + let json = r#"{"WorkflowRunStarted":{"name":"old_wf","run_id":"r1"}}"#; + let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); + assert!(matches!( + deserialized, + WorkflowRunEvent::WorkflowRunStarted { goal: None, .. } + )); + } + + #[test] + fn workflow_run_started_goal_none_omitted_from_json() { + let event = WorkflowRunEvent::WorkflowRunStarted { + name: "wf".to_string(), + run_id: "r1".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: None should be skipped, got: {json}" + ); + } + #[test] fn retro_started_event_serialization() { let event = WorkflowRunEvent::RetroStarted;