mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
Add goal to WorkflowRunStarted and render in fabro logs --pretty (#6)
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<String>` 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
<details>
<summary>Ran 7 stages in 23m 38s for $3.02</summary>
| 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** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```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
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5475926f8d
commit
70ea9f458d
3 changed files with 99 additions and 11 deletions
|
|
@ -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::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) -> Option<String> {
|
||||
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::<Vec<_>>()
|
||||
.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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ pub enum WorkflowRunEvent {
|
|||
run_branch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
worktree_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
goal: Option<String>,
|
||||
},
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue