## Summary
`{{ secrets.NAME }}` tokens in workflow config (MCP transport, prepare
steps, run environment) now resolve from the server vault at the run
boundary — the same late-binding point where `{{ env.* }}` tokens
resolve. Secret values are never persisted and never left literal in
resolved commands or env; a missing or non-Token secret aborts startup
with a clear error.
## What changed
**`fabro-types` — `run.rs`**
- `resolve_env_string` (shared choke-point for MCP and prepare) gains a
`secrets_lookup` parameter and routes through
`ResolveCtx::new().with_env(...).with_secrets(...)` / `resolve_with`.
- `McpServerSettings::resolve_transport_env` and
`RunPrepareSettings::resolve_step_env` thread the new parameter through.
- `RunEnvironmentSettings::resolve_env` becomes fallible
(`Result<HashMap<_,_>, ResolveError>`). Per-value error handling
preserves the historical env fallback for `Namespace::Env`-only errors
while failing closed for `Namespace::Secrets` errors. The intentional
`as_source()` fallback is gated behind its
`#[expect(clippy::disallowed_methods)]` with an explicit reason.
**`fabro-workflow` — `start.rs`**
- A single vault read guard is acquired once at the top of
`RunSession::new`, replacing the previous per-site reads (Daytona key,
etc.).
- `vault_token_lookup` wraps `fabro_auth::vault_get_token` — returning
`Some(value)` only for `Token`-type secrets; `Oauth` and `File` secrets
become `None` (fail-closed).
- The shared `secret_lookup` closure is threaded into
`runtime_mcp_server`, `runtime_setup_commands`, and `resolve_env`.
`resolve_docker_config` gains the same parameter and now returns
`Result`.
**`fabro-sandbox` — `from_environment.rs`**
- `docker_config_from_environment` (server-preflight path, no vault
available) retains `resolve_or_source` behavior unchanged.
- New `docker_config_from_environment_with_secrets` is the vault-backed
variant used by `start.rs`.
**`fabro-cli` — `exec.rs`**
- `fabro exec` has no vault; passes `|_| None` for secrets, preserving
existing behavior with updated call signature.
### Plan summary
- **B.1** — Secret lookup threaded through `resolve_env_string` /
`resolve_transport_env` / `resolve_step_env` / `resolve_env` in
`fabro-types`.
- **B.2** — Vault-backed `secret_lookup` closure built once in
`RunSession::new` and passed to all boundary resolvers in `start.rs`.
- **B.3** — Persistence invariant test: a created run's persisted
`RunCreated` event still carries `{{ secrets.DEPLOY_TOKEN }}` in source
form, not the resolved value.
- **B.4** — Verification (fmt, clippy, nextest, release build) with
hermetic temp-vault tests.
### Key design decisions
- **Fail closed everywhere secrets are referenced** — no source fallback
for secret tokens, even in `resolve_env` which otherwise keeps the env
fallback. This is enforced by checking
`value.references(Namespace::Secrets)` before the fallback branch.
- **Token-only** — `vault_get_token` enforces this; `Oauth` and `File`
secrets silently become `None` and then hard-error via the resolver, not
a panic.
- **Single vault read guard per `RunSession::new`** — acquired once,
shared across MCP / prepare / env resolvers, then dropped before the
struct is returned. Mirrors how the Daytona key was already read.
- **`fabro exec` stays unchanged behaviorally** — the added `|_| None`
secrets argument makes the new signature explicit about having no vault.
### Fabro Details
<details>
<summary>Ran 9 stages in 82m 55s for $24.43</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 21s | – | 0 |
| preflight_lint | 2m 34s | – | 0 |
| implement | 46m 58s | $15.83 | 0 |
| simplify_opus | 12m 43s | $4.79 | 0 |
| simplify_gpt | 6m 43s | $3.18 | 0 |
| verify | 6m 41s | – | 0 |
| fixup | 4m 33s | $0.63 | 0 |
| **Total** | **82m 55s** | **$24.43** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
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 -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -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. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 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 format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
fabro-workflow
A DOT-based pipeline runner for multi-stage AI workflows. Define workflows as Graphviz digraph files and execute them with pluggable handlers, conditional routing, human-in-the-loop gates, parallel branching, retry policies, and checkpoint-based recovery.
Key Concepts
- Graph -- A directed graph parsed from DOT syntax containing nodes, edges, and attributes. The graph carries a
goaldescribing the pipeline's purpose. - Node -- A workflow step. Graphviz shapes map to handler types (e.g.,
Mdiamond= start,Msquare= exit,box= agent,tab= prompt,diamond= conditional,hexagon= human gate,component= parallel). - Edge -- A connection between nodes with optional
condition,label,weight, andfidelityattributes that control routing. - Handler -- An async trait implementation that executes a node and returns an
Outcome. Built-in handlers includeStartHandler,ExitHandler,AgentHandler,PromptHandler,ConditionalHandler,HumanHandler,ParallelHandler,FanInHandler,CommandHandler, andSubWorkflowHandler. - Outcome -- The result of executing a handler, carrying a
StageOutcome(Success, Fail, PartialSuccess, Retry, Skipped), optional routing hints (preferred_label,suggested_next_ids), and context updates. - Context -- A thread-safe key-value store shared across pipeline stages, supporting snapshots and isolated cloning for parallel branches.
- Interviewer -- A trait for human-in-the-loop interactions. Implementations include
AutoApproveInterviewer,QueueInterviewer,CallbackInterviewer,ConsoleInterviewer, andRecordingInterviewer. - Checkpoint -- A serializable snapshot of execution state (completed nodes, context values) for crash recovery and resume.
Pipeline Definition
Pipelines are defined using Graphviz DOT syntax:
digraph MyPipeline {
graph [goal="Implement and validate a feature"]
rankdir=LR
node [shape=box, timeout="900s"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [label="Plan", prompt="Plan the implementation"]
implement [label="Implement", prompt="Implement the plan"]
validate [label="Validate", prompt="Run tests"]
gate [shape=diamond, label="Tests passing?"]
start -> plan -> implement -> validate -> gate
gate -> exit [label="Yes", condition="outcome=succeeded"]
gate -> implement [label="No", condition="outcome!=succeeded"]
}
Usage
Parsing and Validating a Pipeline
use fabro_workflow::operations::{create, CreateOptions};
let dot_source = r#"digraph Simple {
graph [goal="Run tests"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Run the test suite"]
start -> work -> exit
}"#;
let validated = create(dot_source, CreateOptions::default())
.expect("pipeline should parse");
validated.raise_on_errors().expect("pipeline should validate");
let (graph, _, _) = validated.into_parts();
assert_eq!(graph.name, "Simple");
assert_eq!(graph.goal(), "Run tests");
operations::create parses the DOT source, applies built-in transforms (variable expansion, stylesheet application, preamble injection), and returns diagnostics through Validated.
Running a Pipeline
use fabro_workflow::operations::start;
use fabro_workflow::pipeline;
// Use `operations::start(...)` for the full initialize -> execute -> finalize flow.
// Use `pipeline::initialize(...)` + `pipeline::execute(...)` when you need partial lifecycle control.
Custom Handlers
Implement the Handler trait to add custom node behavior:
use arc_workflows::handler::Handler;
use arc_workflows::context::Context;
use arc_workflows::graph::{Graph, Node};
use arc_workflows::outcome::Outcome;
use arc_workflows::error::ArcError;
use async_trait::async_trait;
use std::path::Path;
struct MyHandler;
#[async_trait]
impl Handler for MyHandler {
async fn execute(
&self,
node: &Node,
context: &Context,
graph: &Graph,
run_dir: &Path,
) -> Result<Outcome, ArcError> {
// Custom logic here
Ok(Outcome::success())
}
}
Model Stylesheets
CSS-like stylesheets control LLM model assignment with specificity-based cascading:
digraph Styled {
graph [
goal="Build feature",
model_stylesheet="
* { model: claude-sonnet-4-5;}
.code { model: claude-opus-4-6; }
#critical_review { model: gpt-5.2;}
"
]
// ...
}
Selectors by specificity: * (universal, 0) < shape (1) < .class (2) < #id (3). Explicit node attributes are never overridden.
Condition Expressions
Edge conditions use a simple expression syntax for routing:
outcome=succeeded
outcome!=failed
outcome=succeeded && context.tests_passed=true
my_flag
Clauses support =, !=, and bare key truthiness checks, joined with &&.
Human-in-the-Loop Gates
Nodes with shape=hexagon or type="human" pause execution for human input. Outgoing edge labels become selectable options, with accelerator key parsing for patterns like [A] Approve and F) Fix.
Parallel Execution
Nodes with shape=component fan out to branches concurrently. Configurable join policies: wait_all (default), first_success.
Checkpoints and Resume
The engine saves a checkpoint after each node. Resume from a checkpoint with engine.run_from_checkpoint(&graph, &config, &checkpoint).
Architecture
parser (DOT -> AST -> Graph)
-> transform (variable expansion, stylesheet, preamble)
-> validation (14 lint rules)
-> engine (execution loop with retry, edge selection, goal gates)
-> handler (pluggable node executors)
-> interviewer (human-in-the-loop I/O)