mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-02 07:12:34 +00:00
Fix hook documentation configuration examples
This commit is contained in:
parent
8f01f9f41d
commit
d1144615bf
3 changed files with 207 additions and 53 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue