From d1144615bfd9cbdf6bcba2da319e5c4bc93a777e Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 20 Oct 2026 14:35:21 -0400 Subject: [PATCH 1/2] Fix hook documentation configuration examples --- docs/public/agents/hooks.mdx | 120 +++++++++------- docs/public/execution/run-configuration.mdx | 6 +- .../fabro-workflow-version/src/lib.rs | 134 ++++++++++++++++++ 3 files changed, 207 insertions(+), 53 deletions(-) diff --git a/docs/public/agents/hooks.mdx b/docs/public/agents/hooks.mdx index e8b83f483..5d36b7bb8 100644 --- a/docs/public/agents/hooks.mdx +++ b/docs/public/agents/hooks.mdx @@ -5,31 +5,43 @@ description: "Run custom logic in response to workflow lifecycle events" Hooks let you run custom logic at key points during a workflow — before a stage starts, after a run completes, when a sandbox is ready, and more. Use them for validation, notifications, guardrails, and orchestration without modifying the workflow graph itself. +The examples below are configuration fragments for `workflow.toml`. Add them to a workflow configuration using the current [run configuration schema](/execution/run-configuration). + ## Hook types Fabro supports four hook types, from simple shell commands to full agent sessions: ### Command -Run a shell command via `sh -c`. The simplest and most common hook type. +Use `script` for a shell command string. This is the simplest and most common hook type. -```toml title="run.toml" -[[hooks]] +```toml title="workflow.toml" +[[run.hooks]] event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" ``` +Alternatively, `command` accepts an array of strings: + +```toml title="workflow.toml" +[[run.hooks]] +event = "post_tool_use" +command = ["cargo", "fmt"] +matcher = "write_file|edit_file|apply_patch" +``` + +Use either `script` or `command` for a command hook. + ### HTTP POST the event context as JSON to an HTTP endpoint. Useful for webhooks, external APIs, and notification services. -```toml title="run.toml" -[[hooks]] +```toml title="workflow.toml" +[[run.hooks]] event = "run_complete" -type = "http" url = "https://hooks.example.com/done" -[hooks.headers] +[run.hooks.headers] X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}" ``` @@ -45,10 +57,9 @@ X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}" A single-turn LLM call that evaluates the event context and returns an `ok`/`block` decision. The model responds with structured JSON. -```toml title="run.toml" -[[hooks]] +```toml title="workflow.toml" +[[run.hooks]] event = "stage_start" -type = "prompt" prompt = "Should this stage proceed given the current context? Check for any red flags." model = "haiku" blocking = true @@ -63,10 +74,10 @@ blocking = true A multi-turn agent session with full tool access (shell, file read/write, grep, glob). The agent can investigate the workspace before making a decision. -```toml title="run.toml" -[[hooks]] +```toml title="workflow.toml" +[[run.hooks]] event = "run_complete" -type = "agent" +agent = "enabled" prompt = "Verify that all tests pass and the code compiles. Run the test suite." model = "sonnet" max_tool_rounds = 10 @@ -75,10 +86,13 @@ blocking = true | Field | Description | |---|---| +| `agent` | Set to `"enabled"` to select an agent hook. | | `prompt` | Task instructions for the agent. | | `model` | Model alias or ID. Defaults to `haiku`. | | `max_tool_rounds` | Maximum tool call rounds before the agent gives up. Default: `50`. | +The `agent = "enabled"` marker selects an agent hook; `prompt` supplies its instructions. Without that marker, `prompt` selects a single-turn prompt hook. + ## Lifecycle events Each hook fires on a specific lifecycle event: @@ -104,34 +118,41 @@ Each hook fires on a specific lifecycle event: ## Configuration -Hooks are defined as `[[hooks]]` entries in any of these TOML config files: +Hooks are defined as `[[run.hooks]]` entries in any of these TOML config files: - **`.fabro/project.toml`** — project-level hooks, apply to all workflows in the project - **`workflow.toml`** — per-workflow hooks - **`~/.fabro/settings.toml`** — global defaults for all runs +Each entry selects one transport: `script` or `command`, `url`, `prompt`, or `agent = "enabled"` together with `prompt`. Do not add a `type` field; the transport fields select the hook type. + See [Merging hook configs](#merging-hook-configs) for how these layers combine. -```toml title="run.toml" -[[hooks]] -name = "pre-check" +```toml title="workflow.toml" +[[run.hooks]] +id = "pre-check" +name = "Pre-check script" event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" matcher = "agent" blocking = true -timeout_ms = 30000 +timeout = "30s" sandbox = false ``` | Field | Description | |---|---| -| `name` | Optional display name. Auto-generated from event and type if omitted. | +| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. | +| `name` | Optional display name; does not control merging. If omitted, the hook uses `id` or an auto-generated label. | | `event` | The lifecycle event to listen for (required). | -| `command` | Shell command shorthand — implies `type = "command"`. | -| `type` | Explicit hook type: `"command"`, `"http"`, `"prompt"`, or `"agent"`. | +| `script` | Shell command string. | +| `command` | Command and arguments as an array of strings, such as `["cargo", "fmt"]`; an alternative to `script`. | +| `url` | Selects an HTTP hook. | +| `prompt` | Instructions for a prompt hook, or for an agent hook when `agent` is set. | +| `agent` | Set to `"enabled"` to select an agent hook. | | `matcher` | Regex pattern to filter which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | -| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s) for most types, `30000` (30s) for prompt hooks. | +| `timeout` | Duration string with a unit, such as `"500ms"`, `"30s"`, or `"2m"`. Default: `"30s"` for prompt hooks, `"60s"` for other types. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | ## Blocking vs. non-blocking @@ -211,34 +232,33 @@ For tool events, the matcher is tested against the tool's internal name (e.g., ` This example auto-formats Rust code whenever the agent writes or edits a file: -```toml -[[hooks]] +```toml title="workflow.toml" +[[run.hooks]] name = "cargo-fmt" event = "post_tool_use" -command = "cargo fmt" +script = "cargo fmt" matcher = "write_file|edit_file|apply_patch" blocking = true ``` More examples: -```toml +```toml title="workflow.toml" # Only fire for agent handler nodes -[[hooks]] +[[run.hooks]] event = "stage_start" -command = "./scripts/agent-guard.sh" +script = "./scripts/agent-guard.sh" matcher = "^agent$" # Fire for any node with "test" in its ID -[[hooks]] +[[run.hooks]] event = "stage_complete" -command = "./scripts/report-test.sh" +script = "./scripts/report-test.sh" matcher = "test" # Gate shell commands with a guardrail -[[hooks]] +[[run.hooks]] event = "pre_tool_use" -type = "prompt" prompt = "Is this shell command safe to run?" matcher = "^shell$" model = "haiku" @@ -327,7 +347,7 @@ Each hook type has a default timeout: | Prompt | 30 seconds | | Agent | 60 seconds | -Override with `timeout_ms` on any hook definition. Prompt and agent hooks **fail open** on timeout — execution proceeds as if the hook returned `ok: true`. +Override with `timeout` on any hook definition, using a duration string such as `timeout = "30s"` or `timeout = "2m"`. Supported units are `ms`, `s`, `m`, `h`, and `d`. Prompt and agent hooks **fail open** on timeout — execution proceeds as if the hook returned `ok: true`. ## Fail-open behavior @@ -343,61 +363,61 @@ Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) pro ## Merging hook configs -Hooks from multiple config files are merged in this order (later layers win on name collisions): +Hooks from multiple config files are merged in this order (later layers replace hooks with the same `id`): 1. **`~/.fabro/settings.toml`** — global defaults 2. **`.fabro/project.toml`** — project-level overrides 3. **`workflow.toml`** — per-workflow overrides -This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow. +Hooks with matching `id` values replace in place. Hooks without an `id` append after inherited hooks, even when their `name` matches. Use `id` when a workflow should override an inherited hook. + +This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow. See [configuration precedence](/execution/run-configuration#precedence) for the complete layer order. ## Full example -```toml title="run.toml" +```toml title="workflow.toml" # Validate environment before the run starts -[[hooks]] +[[run.hooks]] name = "env-check" event = "run_start" -command = "./scripts/check-env.sh" +script = "./scripts/check-env.sh" blocking = true sandbox = false # Notify Slack when a stage completes -[[hooks]] +[[run.hooks]] name = "slack-notify" event = "stage_complete" -type = "http" url = "https://hooks.slack.com/workflows/T00/B00/abc123" tls = "verify" blocking = false # LLM guardrail before agent stages -[[hooks]] +[[run.hooks]] name = "safety-check" event = "stage_start" -type = "prompt" prompt = "Review the event context. Is there any reason this stage should not proceed?" model = "haiku" matcher = "^agent$" blocking = true -timeout_ms = 15000 +timeout = "15s" # Post-run verification agent -[[hooks]] +[[run.hooks]] name = "verify" event = "run_complete" -type = "agent" +agent = "enabled" prompt = "Run the test suite and verify all tests pass. Report any failures." model = "sonnet" max_tool_rounds = 20 blocking = true -timeout_ms = 120000 +timeout = "2m" # Auto-format Rust files after writes -[[hooks]] +[[run.hooks]] name = "cargo-fmt" event = "post_tool_use" -command = "cargo fmt" +script = "cargo fmt" matcher = "write_file|edit_file|apply_patch" blocking = true ``` diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index b4bc7b4f5..d7e127e5d 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -582,11 +582,11 @@ sandbox = false | `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. | | `name` | Optional display name for the hook. | | `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. | -| `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). | -| `command` | Argv-style command (alternative to `script`). | +| `script` | Shell command string. | +| `command` | Command and arguments as an array of strings, such as `["cargo", "fmt"]`; an alternative to `script`. | | `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | -| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. | +| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"30s"` for prompt hooks, `"60s"` for other types. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list. diff --git a/lib/components/fabro-workflow-version/src/lib.rs b/lib/components/fabro-workflow-version/src/lib.rs index 945c34e7f..dbc7bb2ab 100644 --- a/lib/components/fabro-workflow-version/src/lib.rs +++ b/lib/components/fabro-workflow-version/src/lib.rs @@ -457,6 +457,140 @@ mod tests { ) } + fn hook_documentation_toml(markdown: &str) -> Vec<&str> { + let snippets: Vec<_> = markdown + .split("```toml") + .skip(1) + .map(|fence| { + let (_, body) = fence.split_once('\n').expect("TOML fence has a body"); + body.split_once("```").expect("TOML fence is closed").0 + }) + .collect(); + assert!( + !snippets.is_empty(), + "hook documentation must contain TOML examples" + ); + snippets + } + + #[test] + fn hook_documentation_examples_register_and_resolve() { + use std::collections::{BTreeMap, BTreeSet}; + + use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder}; + use fabro_types::settings::InterpString; + use fabro_types::settings::run::HookType; + + let guide = include_str!("../../../../docs/public/agents/hooks.mdx"); + let reference = include_str!("../../../../docs/public/execution/run-configuration.mdx"); + let reference = reference + .split_once("### `[[run.hooks]]`") + .expect("hook reference section exists") + .1 + .split_once("\n## ") + .expect("hook reference section has an end") + .0; + let catalog: SettingsLayer = "[environments.default]\nprovider = \"local\"\n" + .parse() + .unwrap(); + let mut transports = BTreeMap::<_, usize>::new(); + let mut timeouts = BTreeSet::new(); + let mut saw_argv = false; + let mut saw_headers = false; + for (document, markdown) in [("guide", guide), ("reference", reference)] { + for (index, snippet) in hook_documentation_toml(markdown).into_iter().enumerate() { + version_with_config(snippet, []).unwrap_or_else(|error| { + panic!( + "{document} TOML example {} must register: {error:?}", + index + 1 + ) + }); + let layer: SettingsLayer = snippet.parse().unwrap(); + let entries = &layer.run.as_ref().expect("example has run settings").hooks; + assert!(!entries.is_empty(), "example must define hooks"); + let settings = WorkflowSettingsBuilder::new() + .server_manifest_defaults(RunLayer::default(), catalog.environments.clone()) + .workflow_toml(snippet) + .unwrap() + .build() + .unwrap_or_else(|error| { + panic!( + "{document} TOML example {} must resolve: {error:?}", + index + 1 + ) + }); + assert_eq!(settings.run.hooks.len(), entries.len()); + for (entry, hook) in entries.iter().zip(&settings.run.hooks) { + match hook + .resolved_hook_type() + .as_deref() + .expect("hook has a transport") + { + HookType::Command { command } => { + *transports.entry("command").or_default() += 1; + if let Some(script) = &entry.script { + assert_eq!(command, script); + } else { + assert_eq!(entry.command.as_ref().unwrap().len(), 2); + assert_eq!(command, &InterpString::parse("cargo fmt")); + saw_argv = true; + } + } + HookType::Http { url, headers, .. } => { + *transports.entry("http").or_default() += 1; + assert_eq!(Some(url), entry.url.as_ref()); + if !entry.headers.is_empty() { + assert_eq!(headers.as_ref().unwrap(), &entry.headers); + saw_headers = true; + } + } + HookType::Prompt { prompt, .. } => { + *transports.entry("prompt").or_default() += 1; + assert!(entry.agent.is_none()); + assert_eq!(Some(prompt), entry.prompt.as_ref()); + } + HookType::Agent { + prompt, + max_tool_rounds, + .. + } => { + *transports.entry("agent").or_default() += 1; + assert!(entry.agent.is_some()); + assert_eq!(Some(prompt), entry.prompt.as_ref()); + assert_eq!(max_tool_rounds, &entry.max_tool_rounds); + } + } + if let Some(timeout) = hook.timeout_ms { + timeouts.insert(timeout); + } + } + } + } + assert_eq!( + transports, + [("agent", 2), ("command", 9), ("http", 2), ("prompt", 3)].into() + ); + assert_eq!(timeouts, [15_000, 30_000, 120_000].into()); + assert!(saw_argv, "guide must demonstrate vector-valued command"); + assert!(saw_headers, "guide must demonstrate nested HTTP headers"); + } + + #[test] + fn hook_documentation_obsolete_fields_remain_rejected() { + for source in [ + "[[hooks]]\nevent = \"stage_start\"\nscript = \"true\"\n", + "[[run.hooks]]\nevent = \"stage_start\"\ntype = \"prompt\"\nprompt = \"Check\"\n", + "[[run.hooks]]\nevent = \"run_complete\"\ntype = \"http\"\nurl = \"https://example.com\"\n", + "[[run.hooks]]\nevent = \"stage_start\"\ncommand = \"true\"\n", + "[[run.hooks]]\nevent = \"stage_start\"\nscript = \"true\"\ntimeout_ms = 30000\n", + ] { + assert!(matches!( + version_with_config(source, []), + Err(WorkflowVersionError::Config { .. }) + )); + } + } + fn version_with_goal_file( reference: &str, ) -> Result { From dc12a9d2b5b9971619911b8a229c64e1d51d19cf Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 20 Oct 2026 15:23:21 -0400 Subject: [PATCH 2/2] Keep hook documentation fix documentation-only --- .../fabro-workflow-version/src/lib.rs | 134 ------------------ 1 file changed, 134 deletions(-) diff --git a/lib/components/fabro-workflow-version/src/lib.rs b/lib/components/fabro-workflow-version/src/lib.rs index dbc7bb2ab..945c34e7f 100644 --- a/lib/components/fabro-workflow-version/src/lib.rs +++ b/lib/components/fabro-workflow-version/src/lib.rs @@ -457,140 +457,6 @@ mod tests { ) } - fn hook_documentation_toml(markdown: &str) -> Vec<&str> { - let snippets: Vec<_> = markdown - .split("```toml") - .skip(1) - .map(|fence| { - let (_, body) = fence.split_once('\n').expect("TOML fence has a body"); - body.split_once("```").expect("TOML fence is closed").0 - }) - .collect(); - assert!( - !snippets.is_empty(), - "hook documentation must contain TOML examples" - ); - snippets - } - - #[test] - fn hook_documentation_examples_register_and_resolve() { - use std::collections::{BTreeMap, BTreeSet}; - - use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder}; - use fabro_types::settings::InterpString; - use fabro_types::settings::run::HookType; - - let guide = include_str!("../../../../docs/public/agents/hooks.mdx"); - let reference = include_str!("../../../../docs/public/execution/run-configuration.mdx"); - let reference = reference - .split_once("### `[[run.hooks]]`") - .expect("hook reference section exists") - .1 - .split_once("\n## ") - .expect("hook reference section has an end") - .0; - let catalog: SettingsLayer = "[environments.default]\nprovider = \"local\"\n" - .parse() - .unwrap(); - let mut transports = BTreeMap::<_, usize>::new(); - let mut timeouts = BTreeSet::new(); - let mut saw_argv = false; - let mut saw_headers = false; - for (document, markdown) in [("guide", guide), ("reference", reference)] { - for (index, snippet) in hook_documentation_toml(markdown).into_iter().enumerate() { - version_with_config(snippet, []).unwrap_or_else(|error| { - panic!( - "{document} TOML example {} must register: {error:?}", - index + 1 - ) - }); - let layer: SettingsLayer = snippet.parse().unwrap(); - let entries = &layer.run.as_ref().expect("example has run settings").hooks; - assert!(!entries.is_empty(), "example must define hooks"); - let settings = WorkflowSettingsBuilder::new() - .server_manifest_defaults(RunLayer::default(), catalog.environments.clone()) - .workflow_toml(snippet) - .unwrap() - .build() - .unwrap_or_else(|error| { - panic!( - "{document} TOML example {} must resolve: {error:?}", - index + 1 - ) - }); - assert_eq!(settings.run.hooks.len(), entries.len()); - for (entry, hook) in entries.iter().zip(&settings.run.hooks) { - match hook - .resolved_hook_type() - .as_deref() - .expect("hook has a transport") - { - HookType::Command { command } => { - *transports.entry("command").or_default() += 1; - if let Some(script) = &entry.script { - assert_eq!(command, script); - } else { - assert_eq!(entry.command.as_ref().unwrap().len(), 2); - assert_eq!(command, &InterpString::parse("cargo fmt")); - saw_argv = true; - } - } - HookType::Http { url, headers, .. } => { - *transports.entry("http").or_default() += 1; - assert_eq!(Some(url), entry.url.as_ref()); - if !entry.headers.is_empty() { - assert_eq!(headers.as_ref().unwrap(), &entry.headers); - saw_headers = true; - } - } - HookType::Prompt { prompt, .. } => { - *transports.entry("prompt").or_default() += 1; - assert!(entry.agent.is_none()); - assert_eq!(Some(prompt), entry.prompt.as_ref()); - } - HookType::Agent { - prompt, - max_tool_rounds, - .. - } => { - *transports.entry("agent").or_default() += 1; - assert!(entry.agent.is_some()); - assert_eq!(Some(prompt), entry.prompt.as_ref()); - assert_eq!(max_tool_rounds, &entry.max_tool_rounds); - } - } - if let Some(timeout) = hook.timeout_ms { - timeouts.insert(timeout); - } - } - } - } - assert_eq!( - transports, - [("agent", 2), ("command", 9), ("http", 2), ("prompt", 3)].into() - ); - assert_eq!(timeouts, [15_000, 30_000, 120_000].into()); - assert!(saw_argv, "guide must demonstrate vector-valued command"); - assert!(saw_headers, "guide must demonstrate nested HTTP headers"); - } - - #[test] - fn hook_documentation_obsolete_fields_remain_rejected() { - for source in [ - "[[hooks]]\nevent = \"stage_start\"\nscript = \"true\"\n", - "[[run.hooks]]\nevent = \"stage_start\"\ntype = \"prompt\"\nprompt = \"Check\"\n", - "[[run.hooks]]\nevent = \"run_complete\"\ntype = \"http\"\nurl = \"https://example.com\"\n", - "[[run.hooks]]\nevent = \"stage_start\"\ncommand = \"true\"\n", - "[[run.hooks]]\nevent = \"stage_start\"\nscript = \"true\"\ntimeout_ms = 30000\n", - ] { - assert!(matches!( - version_with_config(source, []), - Err(WorkflowVersionError::Config { .. }) - )); - } - } - fn version_with_goal_file( reference: &str, ) -> Result {