Remove run preparation settings in favor of explicit commands

This commit is contained in:
Scott Werner 2026-10-22 15:01:03 -04:00
parent d45464eb9b
commit b452f498f5
46 changed files with 1067 additions and 1028 deletions

View file

@ -36,10 +36,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
@ -511,10 +507,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"

View file

@ -36,10 +36,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
@ -633,10 +629,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"

View file

@ -36,10 +36,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
@ -809,10 +805,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"

View file

@ -36,10 +36,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
@ -833,10 +829,6 @@
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"

View file

@ -18,7 +18,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@ -87,9 +87,6 @@ provider = "anthropic"
[run.model.fallbacks]
"claude-sonnet-4-5" = ["gemini", "openai"]
[[run.prepare.steps]]
script = "npm install"
[run.environment]
id = "cloud"
@ -281,7 +278,7 @@ Run creation uses the registered workflow version's `workflow.toml` above the se
Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` come from the server machine's `settings.toml` or `fabro server start` flags. Environments and MCP server references resolve against the server's catalogs.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, explicit intent inputs merge per key at highest precedence, environment `env` and `labels` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, explicit intent inputs merge per key at highest precedence, environment `env` and `labels` merge by key, `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
### `[server.logging]` section
@ -300,7 +297,7 @@ The CLI has its own `[cli.logging]` section.
### `[run.git.author]` section
Override the Git author and committer identity for every commit a run creates: Fabro's own checkpoint and metadata commits, and any `git commit` a prepare step, command stage, or agent tool runs inside the sandbox. Fabro resolves one identity per run and injects it as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` into every workflow command, so the primary checkout, additional clones, and repositories a workflow clones itself all commit as the same identity. It does not write to any Git configuration file.
Override the Git author and committer identity for every commit a run creates: Fabro's own checkpoint and metadata commits, and any `git commit` a command stage or agent tool runs inside the sandbox. Fabro resolves one identity per run and injects it as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` into every workflow command, so the primary checkout, additional clones, and repositories a workflow clones itself all commit as the same identity. It does not write to any Git configuration file.
When a field is not set, Fabro derives it from the run's GitHub credential:
@ -511,3 +508,8 @@ fabro secret set FABRO_SLACK_APP_TOKEN xapp-...
|---|---|---|
| `FABRO_LOG` | `info` | Log level: `error`, `warn`, `info`, `debug` |
| `FABRO_LOG_DESTINATION` | Command-mode default | Server log destination: `file` or `stdout` |
Setup commands belong in each workflow as explicit command nodes after `start`,
or in a shared script those nodes invoke. The removed `run.prepare` setting is
rejected even when a workflow would override it. See the
[setup migration guide](/execution/run-configuration#setup-command-migration).

View file

@ -12806,8 +12806,7 @@ components:
The display graph of a run: the admitted workflow's name, goal,
stages and edges, read off the graph Petri admitted at create time.
Lowering artifacts (the goal check, a synthetic fan-in) are left out;
the stages are the nodes the workflow declares, imports and
`[run.prepare]` steps included.
the stages are the nodes the workflow declares, including imported nodes.
type: object
required: [name]
properties:
@ -15782,7 +15781,6 @@ components:
- inputs
- model
- git
- prepare
- execution
- checkpoint
- clone
@ -15815,8 +15813,6 @@ components:
$ref: "#/components/schemas/RunModelSettings"
git:
$ref: "#/components/schemas/RunGitSettings"
prepare:
$ref: "#/components/schemas/RunPrepareSettings"
execution:
$ref: "#/components/schemas/RunExecutionSettings"
checkpoint:
@ -15966,63 +15962,6 @@ components:
email:
type: ["string", "null"]
RunPrepareSettings:
type: object
required: [steps, timeout_ms]
properties:
steps:
type: array
items:
$ref: "#/components/schemas/PreparedStep"
timeout_ms:
type: integer
format: int64
PreparedStep:
description: |
A single resolved prepare step. The runnable part preserves the
script-vs-argv distinction via the `type` discriminator: a `script`
is a raw shell snippet kept verbatim, while a `command` is an argv
whose elements are shell-quoted and joined at the run boundary (after
`{{ secrets.* }}` resolution) so an interpolated value cannot inject shell
syntax. Optional per-step `env` is shared by both shapes.
type: object
required: [type]
oneOf:
- $ref: "#/components/schemas/PreparedScriptStep"
- $ref: "#/components/schemas/PreparedCommandStep"
discriminator:
propertyName: type
mapping:
script: "#/components/schemas/PreparedScriptStep"
command: "#/components/schemas/PreparedCommandStep"
PreparedScriptStep:
type: object
required: [type, script]
properties:
type:
type: string
enum: [script]
script:
type: string
env:
$ref: "#/components/schemas/StringMap"
PreparedCommandStep:
type: object
required: [type, command]
properties:
type:
type: string
enum: [command]
command:
type: array
items:
type: string
env:
$ref: "#/components/schemas/StringMap"
RunExecutionSettings:
type: object
required: [mode, approval]

View file

@ -27,6 +27,8 @@ digraph SemanticPort {
]
start [shape=Mdiamond, label="Start"]
fetch_upstream [shape=parallelogram, script="git clone https://github.com/openai/openai-agents-python upstream || (cd upstream && git pull)", timeout="5m", on_failure="exit", max_retries=0]
install_ledger [shape=parallelogram, script="pip install -r ledger/requirements.txt", timeout="5m", on_failure="exit", max_retries=0]
exit [shape=Msquare, label="Exit"]
// Phase 1: Find the next unprocessed commit
@ -119,7 +121,7 @@ digraph SemanticPort {
]
// Wiring
start -> fetch
start -> fetch_upstream -> install_ledger -> fetch
fetch -> analyze [label="Process", condition="preferred_label=process"]
fetch -> exit [label="Done"]
@ -230,12 +232,6 @@ provider = "anthropic"
"claude-sonnet-4-5" = ["openai"]
"gemini-3.1-pro-preview" = ["anthropic"]
[[run.prepare.steps]]
script = "git clone https://github.com/openai/openai-agents-python upstream || (cd upstream && git pull)"
[[run.prepare.steps]]
script = "pip install -r ledger/requirements.txt"
[run.inputs]
upstream_repo = "openai/openai-agents-python"
downstream_lang = "go"

View file

@ -29,6 +29,7 @@ digraph BuildSolitaire {
]
start [shape=Mdiamond, label="Start"]
install_dependencies [shape=parallelogram, script="python3 -m venv .venv && . .venv/bin/activate && pip install pytest curses", timeout="5m", on_failure="exit", max_retries=0]
exit [shape=Msquare, label="Exit"]
// Phase 0: Expand the goal into a detailed spec
@ -148,7 +149,7 @@ digraph BuildSolitaire {
check_review [shape=diamond, label="Review OK?"]
// Wiring: linear phases with verify-gate loops
start -> expand_spec -> impl_setup -> verify_setup -> check_setup
start -> install_dependencies -> expand_spec -> impl_setup -> verify_setup -> check_setup
check_setup -> impl_data [condition="outcome=succeeded"]
check_setup -> impl_setup [condition="outcome=failed", label="Retry"]
@ -270,9 +271,6 @@ provider = "anthropic"
[run.model.fallbacks]
"claude-sonnet-4-5" = ["openai", "gemini"]
[[run.prepare.steps]]
script = "python3 -m venv .venv && . .venv/bin/activate && pip install pytest curses"
[run.environment]
id = "python-dev"

View file

@ -4,7 +4,7 @@ description: "Configure workflow runs with TOML files"
---
A workflow config is a TOML file that bundles a workflow graph with its
execution behavior — the goal, model, prepare steps, inputs, hooks, and other
execution behavior — the goal, model, inputs, hooks, and other
workflow-owned settings. Instead of passing a dozen CLI flags, check
`workflow.toml` into version control and launch it with a single command:
@ -75,12 +75,6 @@ reasoning_effort = "high"
[run.model.fallbacks]
"claude-sonnet-4-5" = ["openai", "gemini"]
[[run.prepare.steps]]
script = "git clone https://github.com/fabro-sh/fabro repo"
[[run.prepare.steps]]
script = "cd repo && npm install"
[run.integrations.github.permissions]
contents = "write"
pull_requests = "write"
@ -208,28 +202,100 @@ Use the reserved `"..."` marker in one model's list to splice in that model's in
"gpt-5.6-sol" = ["anthropic", "..."]
```
### `[run.prepare]`
### Setup command migration
Ordered list of steps to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment.
`run.prepare` is no longer supported in any authored settings file, including
server, workflow, direct-run, project, and user settings. Move setup into named
command nodes after `start`. Setup is visible in the graph and timeline and uses
ordinary stage hooks and failure handling. Review broad hook matchers that
assume dependencies are already installed: they now also match setup commands.
```toml title="run.toml"
[[run.prepare.steps]]
script = "pip install -r requirements.txt"
Before, a workflow whose graph was `start -> work -> exit` could use:
[[run.prepare.steps]]
command = ["npm", "install"]
env = { NPM_TOKEN = "{{ secrets.NPM_TOKEN }}" }
```toml title="workflow.toml (removed syntax)"
[run.prepare]
timeout = "5m"
steps = [
{ command = ["bundle", "install"] },
{ script = "bundle exec rake db:prepare", env = { TOKEN = "{{ secrets.SETUP_TOKEN }}" } },
]
```
| Field | Description |
|---|---|
| `script` | Bash source, evaluated by the sandbox's non-login Bash (`bash -c`). Supports `{{ vars.* }}` and `{{ secrets.* }}` interpolation. |
| `command` | Argv-style command, mutually exclusive with `script`. Each resolved element is shell-quoted as one argument. |
| `env` | Additional environment variables for this step. Values support the same interpolation as `script` and `command`. |
Remove that section. Use the existing workflow-wide environment for secrets:
Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale.
```toml title="workflow.toml"
_version = 1
[workflow]
graph = "workflow.fabro"
Fabro substitutes `{{ vars.* }}` when the server creates the run, then resolves `{{ secrets.* }}` from token entries in the server vault immediately before the worker executes the steps. Secret expressions remain in the persisted run definition; resolved secret values are not persisted. A missing or non-token secret aborts startup with the affected step and token named in the error.
[run.environment.env]
SETUP_TOKEN = "{{ secrets.SETUP_TOKEN }}"
```
Then put setup in the workflow:
```dot title="workflow.fabro"
digraph Workflow {
start [shape=Mdiamond]
install [shape=parallelogram, script="bundle install", timeout="5m", on_failure="exit", max_retries=0]
database [shape=parallelogram, script="TOKEN=\"$SETUP_TOKEN\" bundle exec rake db:prepare", timeout="5m", on_failure="exit", max_retries=0]
work [shape=parallelogram, script="bundle exec rake test"]
exit [shape=Msquare]
start -> install -> database -> work -> exit
}
```
The timeout applies **per command**. Set `timeout="5m"` to retain preparation's
old default; ordinary commands otherwise default to ten minutes. Keep
`on_failure="exit"` so a failed setup command prevents downstream work. The
example assumes no graph retry defaults or exhaustion override. Where those
exist, preserve the inherited retry count and `on_retries_exhausted` policy rather
than blindly copying `max_retries=0`. Ordinary shell exit failures do not request
retries; a nonzero graph retry default alone does not retry them.
For branching workflows, move every outgoing edge from `start` to the final
setup node, preserving its attributes. Keep the edges between setup commands
unconditional. Keep setup in the scope that owns the checkout and selected
environment; repository checkout still happens before these commands.
**Secret scope changes:** a formerly per-step secret moved into
`[run.environment.env]` (or the selected environment's `env` table) is available
to every command and process in that workflow environment, including `work` in
this example. Resolution remains late and output remains redacted. This is
broader exposure than the old per-step overlay. Do not put resolved secrets into
scripts or print them. When two old steps used `TOKEN` for different secrets,
use distinct workflow keys such as `FIRST_TOKEN` and `SECOND_TOKEN`, then map
with `TOKEN="$FIRST_TOKEN" ./scripts/setup-one.sh` and
`TOKEN="$SECOND_TOKEN" ./scripts/setup-two.sh`. Both source keys remain available
workflow-wide. Literal values can stay command-local: `MODE=first ./setup.sh`.
Convert argv values to safely quoted shell arguments. For example, the argv
argument `a space, 'quote'; $HOME` can be expressed without expansion as:
```dot
quoted [shape=parallelogram, script="printf '%s\n' 'a space, '\"'\"'quote'\"'\"'; $HOME'", timeout="5m", on_failure="exit", max_retries=0]
```
Keep supported input/variable template tokens in their existing script renderer;
do not turn them into shell command substitutions. Quote shell environment
expansions as in the secret example.
Server-wide setup must move into each applicable workflow, or into a shared
script explicitly called by those workflows. Fabro does not choose a repository
or rewrite user files automatically. Environment images and Dockerfiles remain
available for installing tools and dependencies.
Historical runs retain their saved admitted commands, encoded per-command env
overlays, graph digests, and events. Reading, resuming, retrying, or forking a saved
run uses its admission; it does not recompile retired syntax. Submitting an old
workflow version as a new run does reject it. Executing commands named
`run_prepare_…`, including old generated commands, now invokes ordinary stage
hooks; the old prefix-based hook exemption is removed. Existing encoded secret
overlays keep their original per-command scope.
The resolved settings API no longer emits `run.prepare` or its preparation
schemas. Upgrade server, workers, and clients together; clients that required
that response field must update. Saved records are not rewritten by this change.
### `[run.clone]`
@ -241,7 +307,7 @@ enabled = true
depth = 100
```
Set `enabled = false` to start runs with an empty workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs.
Set `enabled = false` to start runs with an empty workspace. Use [explicit setup commands](#setup-command-migration) to clone or create any files the workflow needs.
| Field | Description |
|---|---|
@ -538,7 +604,7 @@ id = "sentry"
| `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` |
| `tool_timeout` | Max duration for a single tool call. | `"60s"` |
Inline transport commands, URLs, env values, and headers support `{{ vars.* }}` and `{{ secrets.* }}` interpolation. As with prepare steps, server variables resolve at run creation and token secrets resolve at launch; missing values fail closed. See [MCP runtime interpolation](/agents/mcp#runtime-interpolation) for the standalone `fabro exec` difference.
Inline transport commands, URLs, env values, and headers support `{{ vars.* }}` and `{{ secrets.* }}` interpolation. Server variables resolve at run creation and token secrets resolve at launch; missing values fail closed. See [MCP runtime interpolation](/agents/mcp#runtime-interpolation) for the standalone `fabro exec` difference.
The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details.

View file

@ -316,7 +316,7 @@ The upper bound on what Fabro will mint is whatever permissions the GitHub App i
### Commit attribution
Every commit a run creates is authored and committed by the run's GitHub credential identity. In App mode that is the App's bot account, `<slug>[bot] <id+slug[bot]@users.noreply.github.com>`, so GitHub attributes checkpoint commits and workflow-created commits to the App. In token mode it is the token's user with their GitHub noreply address. Fabro resolves the identity once per run and passes it to every prepare step, command stage, agent tool, and ACP agent as `GIT_AUTHOR_*` / `GIT_COMMITTER_*` variables, so a workflow that clones or initializes another repository commits as the same identity without configuring Git itself. `[run.git.author]` overrides either field; see [server configuration](/administration/server-configuration#rungitauthor-section).
Every commit a run creates is authored and committed by the run's GitHub credential identity. In App mode that is the App's bot account, `<slug>[bot] <id+slug[bot]@users.noreply.github.com>`, so GitHub attributes checkpoint commits and workflow-created commits to the App. In token mode it is the token's user with their GitHub noreply address. Fabro resolves the identity once per run and passes it to every command stage, agent tool, and ACP agent as `GIT_AUTHOR_*` / `GIT_COMMITTER_*` variables, so a workflow that clones or initializes another repository commits as the same identity without configuring Git itself. `[run.git.author]` overrides either field; see [server configuration](/administration/server-configuration#rungitauthor-section).
### Checkpoint pushing

View file

@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
| Scope | Examples |
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.<id>]`, `[llm.providers.<id>.models.<id>]`, and the agent harness under `metadata.agent` |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |

View file

@ -242,8 +242,8 @@ root = "{}"
[run.execution]
approval = "auto"
[[run.prepare.steps]]
script = "cli-setup"
[run.checkpoint]
exclude_globs = ["cli-cache/**"]
"#,
storage_dir.display()
),
@ -257,8 +257,8 @@ script = "cli-setup"
r#"
_version = 1
[[run.prepare.steps]]
script = "project-setup"
[run.checkpoint]
exclude_globs = ["project-cache/**"]
"#,
)
.expect("external workflow project config should write");
@ -289,8 +289,8 @@ goal = "Ship it"
[run.model]
name = "claude-sonnet-4-6"
[[run.prepare.steps]]
script = "workflow-setup"
[run.checkpoint]
exclude_globs = ["workflow-cache/**"]
"#,
)
.expect("external workflow config should write");
@ -366,12 +366,9 @@ fn create_explicit_workflow_warns_without_transmitting_machine_or_project_run_se
run_spec["settings"]["run"]["model"]["name"].as_str(),
Some("gpt-5.4-pro")
);
// run.prepare.steps replaces the whole ordered list across layers. A
// `script` step serializes with the `type` discriminator that preserves the
// script-vs-argv distinction in the run spec wire shape.
assert_eq!(
run_spec["settings"]["run"]["prepare"]["steps"],
serde_json::json!([{ "type": "script", "script": "workflow-setup" }])
run_spec["settings"]["run"]["checkpoint"]["exclude_globs"],
serde_json::json!(["workflow-cache/**"])
);
}

View file

@ -12,6 +12,7 @@ mod petri;
mod petri_controls;
mod petri_docker;
mod petri_fork;
mod petri_setup;
mod petri_tools;
mod server_lifecycle;
mod smoke;

View file

@ -0,0 +1,159 @@
//! Explicit setup commands through a real server and worker, including the
//! intentionally broader workflow-wide scope of migrated secrets.
#![expect(
clippy::disallowed_methods,
reason = "scenario writes workflow inputs and reads workspace output"
)]
use std::path::PathBuf;
use fabro_test::test_context;
use super::petri::{
RunningServer, run_detached, run_json, settled_stream, wait_for_status, write_petri_workflow,
};
fn run_workspace(server: &RunningServer, run_id: &str) -> PathBuf {
let scopes = server.petri_run_dir(run_id).join("scopes");
let mut workspaces: Vec<PathBuf> = std::fs::read_dir(scopes)
.expect("the run has retained scopes")
.map(|entry| entry.expect("a scope reads").path().join("work"))
.collect();
assert_eq!(workspaces.len(), 1, "one workspace: {workspaces:?}");
workspaces.remove(0)
}
#[tokio::test]
async fn explicit_setup_orders_commands_and_shares_redacted_workflow_secrets() {
let context = test_context!();
let secret = "fixture-setup-token-72c9e1";
let server = RunningServer::start_with("", &[("SETUP_TOKEN", secret)]).await;
let workspace = write_petri_workflow(
&context,
r#"digraph Setup {
start [shape=Mdiamond]
quoted [shape=parallelogram, script="printf '%s' 'a space, '\"'\"'quote'\"'\"'; $HOME' > quoted-argument", timeout="5m", on_failure="exit", max_retries=0]
install [shape=parallelogram, script="test -n \"$SETUP_TOKEN\" && printf 'setup token=%s\n' \"$SETUP_TOKEN\" && touch installed", timeout="5m", on_failure="exit", max_retries=0]
database [shape=parallelogram, script="test -f installed && touch prepared", timeout="5m", on_failure="exit", max_retries=0]
work [shape=parallelogram, script="test -f prepared && test -n \"$SETUP_TOKEN\" && touch finished"]
exit [shape=Msquare]
start -> quoted -> install -> database -> work -> exit
}"#,
);
std::fs::write(
workspace.join("workflow.toml"),
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[run.environment.env]
SETUP_TOKEN = "{{ secrets.SETUP_TOKEN }}"
"#,
)
.unwrap();
let run_id = run_detached(&context, &server, &workspace);
assert_eq!(
wait_for_status(&server, &run_id, &["succeeded", "failed"]).await,
"succeeded"
);
let workspace = run_workspace(&server, &run_id);
assert!(workspace.join("finished").exists());
assert_eq!(
std::fs::read_to_string(workspace.join("quoted-argument")).unwrap(),
"a space, 'quote'; $HOME"
);
let state = run_json(&server, &format!("runs/{run_id}/state")).await;
for node in ["quoted", "install", "database", "work"] {
assert_eq!(state["spec"]["graph"]["nodes"][node]["kind"], "command");
}
assert!(state["spec"]["settings"]["run"].get("prepare").is_none());
let stream = settled_stream(&server, &run_id).await;
let serialized = serde_json::to_string(&stream).unwrap();
assert!(
!serialized.contains(secret),
"the stream must redact the workflow secret"
);
assert!(
serialized.contains("setup token=***"),
"the redacted output must remain visible"
);
for node in ["quoted", "install", "database", "work"] {
assert!(
stream
.iter()
.any(|item| item["item"]["subject"]["node"]["name"] == node
&& item["item"]["derived"]["event"] == "visit.completed"),
"missing events for {node}"
);
}
server.shutdown();
}
#[tokio::test]
async fn failed_setup_stops_downstream_work_without_retrying_shell_exit() {
let context = test_context!();
let server = RunningServer::start().await;
let workspace = write_petri_workflow(
&context,
r#"digraph SetupFailure {
graph [default_max_retries=3]
start [shape=Mdiamond]
setup [shape=parallelogram, script="echo attempt >> attempts; exit 1", timeout="5m", on_failure="exit"]
work [shape=parallelogram, script="touch should-not-exist"]
exit [shape=Msquare]
start -> setup -> work -> exit
}"#,
);
let run_id = run_detached(&context, &server, &workspace);
assert_eq!(
wait_for_status(&server, &run_id, &["succeeded", "failed"]).await,
"failed"
);
let workspace = run_workspace(&server, &run_id);
assert_eq!(
std::fs::read_to_string(workspace.join("attempts")).unwrap(),
"attempt\n"
);
assert!(!workspace.join("should-not-exist").exists());
server.shutdown();
}
#[tokio::test]
async fn a_command_with_the_former_preparation_prefix_receives_ordinary_hooks() {
let context = test_context!();
let server = RunningServer::start().await;
let workspace = write_petri_workflow(
&context,
r#"digraph Hooked {
start [shape=Mdiamond]
run_prepare_1 [shape=parallelogram, script="touch prepared", timeout="5m", on_failure="exit"]
exit [shape=Msquare]
start -> run_prepare_1 -> exit
}"#,
);
std::fs::write(
workspace.join("workflow.toml"),
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[[run.hooks]]
event = "stage_start"
matcher = "^run_prepare_1$"
script = "echo hook >> hooks"
"#,
)
.unwrap();
let run_id = run_detached(&context, &server, &workspace);
assert_eq!(
wait_for_status(&server, &run_id, &["succeeded", "failed"]).await,
"succeeded"
);
let workspace = run_workspace(&server, &run_id);
assert!(workspace.join("prepared").exists());
assert_eq!(
std::fs::read_to_string(workspace.join("hooks")).unwrap(),
"hook\n"
);
server.shutdown();
}

View file

@ -1054,8 +1054,7 @@ mod runs {
use fabro_api::types::*;
use fabro_types::settings::run::{
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentResourcesSettings,
EnvironmentSettings, PreparedStep, PreparedStepRun, RunEnvironmentSettings, RunGoal,
RunModelSettings, RunNamespace, RunPrepareSettings,
EnvironmentSettings, RunEnvironmentSettings, RunGoal, RunModelSettings, RunNamespace,
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
@ -1868,23 +1867,6 @@ mod runs {
name: Some("claude-opus-4-6".to_string()),
..RunModelSettings::default()
},
prepare: RunPrepareSettings {
steps: vec![
PreparedStep {
run: PreparedStepRun::Script {
script: "bun install".to_string(),
},
env: HashMap::new(),
},
PreparedStep {
run: PreparedStepRun::Script {
script: "bun run typecheck".to_string(),
},
env: HashMap::new(),
},
],
timeout_ms: 120_000,
},
environment: RunEnvironmentSettings::from_environment(
"api-server".to_string(),
environment,

View file

@ -251,7 +251,7 @@ pub(crate) enum InvalidSourceError {
#[derive(Debug, thiserror::Error)]
pub(crate) enum InvalidSettingsError {
#[error("Failed to parse run config TOML")]
#[error("Failed to parse run config TOML at {path}")]
Parse {
path: ManifestPath,
#[source]

View file

@ -140,7 +140,15 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
.filter(|config| config.type_ == types::ManifestConfigType::User)
{
if let Some(source) = config.source.as_deref() {
workflow_settings_builder = workflow_settings_builder.user_toml(source)?;
workflow_settings_builder =
workflow_settings_builder
.user_toml(source)
.with_context(|| {
format!(
"Failed to parse user settings at {}",
config.path.as_deref().unwrap_or("manifest user settings")
)
})?;
}
}
let mut settings = workflow_settings_builder
@ -525,7 +533,6 @@ async fn build_preflight_report(
}
fn base_preflight_checks(prepared: &PreparedManifest, shape: &WorkflowShape) -> Vec<CheckResult> {
let setup_command_count = prepared.settings.run.prepare.steps.len();
let repo_summary = prepared.git.as_ref().map_or_else(
|| "unknown".to_string(),
|git| {
@ -542,26 +549,23 @@ fn base_preflight_checks(prepared: &PreparedManifest, shape: &WorkflowShape) ->
name: "Repository".into(),
status: CheckStatus::Pass,
summary: repo_summary,
details: vec![
CheckDetail::new(format!("Setup commands: {setup_command_count}")),
CheckDetail {
text: format!(
"Git: {}",
prepared
.git
.as_ref()
.map_or("unknown", |git| match git.dirty {
fabro_types::DirtyStatus::Clean => "clean",
fabro_types::DirtyStatus::Dirty => "dirty",
fabro_types::DirtyStatus::Unknown => "unknown",
})
),
warn: prepared
details: vec![CheckDetail {
text: format!(
"Git: {}",
prepared
.git
.as_ref()
.is_some_and(|git| git.dirty != fabro_types::DirtyStatus::Clean),
},
],
.map_or("unknown", |git| match git.dirty {
fabro_types::DirtyStatus::Clean => "clean",
fabro_types::DirtyStatus::Dirty => "dirty",
fabro_types::DirtyStatus::Unknown => "unknown",
})
),
warn: prepared
.git
.as_ref()
.is_some_and(|git| git.dirty != fabro_types::DirtyStatus::Clean),
}],
remediation: None,
},
CheckResult {
@ -2313,6 +2317,37 @@ override = "server"
);
}
#[test]
fn prepare_manifest_rejects_removed_preparation_in_supplied_sources() {
for kind in [
types::ManifestConfigType::User,
types::ManifestConfigType::Project,
] {
let mut manifest = minimal_manifest();
manifest.configs.push(types::ManifestConfig {
path: Some("retired.toml".into()),
source: Some("[run.prepare]\nsteps = []".into()),
type_: kind,
});
let error = prepare_manifest(&RunLayer::default(), &manifest)
.err()
.expect("retired preparation must fail");
assert!(format!("{error:#}").contains("run.prepare"));
}
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
Some(types::ManifestWorkflowConfig {
path: "workflow.toml".into(),
source: "[run.prepare]\nsteps = []".into(),
});
let error = prepare_manifest(&RunLayer::default(), &manifest)
.err()
.expect("retired preparation must fail");
let message = format!("{error:#}");
assert!(message.contains("run.prepare"));
assert!(message.contains("workflow.toml"));
}
#[test]
fn prepare_manifest_prefers_bundled_settings_without_duplication() {
let server_settings = manifest_run_defaults(Some(&server_settings_fixture(
@ -2322,8 +2357,8 @@ _version = 1
[server.storage]
root = "/srv/fabro"
[[run.prepare.steps]]
script = "cli-setup"
[run.checkpoint]
exclude_globs = ["cli-cache/**"]
[server.integrations.github]
app_id = "fixture-app-id"
@ -2337,8 +2372,8 @@ app_id = "fixture-app-id"
source: r#"
_version = 1
[[run.prepare.steps]]
script = "workflow-setup"
[run.checkpoint]
exclude_globs = ["workflow-cache/**"]
"#
.to_string(),
});
@ -2351,8 +2386,8 @@ _version = 1
[server.auth]
methods = ["dev-token"]
[[run.prepare.steps]]
script = "cli-setup"
[run.checkpoint]
exclude_globs = ["cli-cache/**"]
[server.integrations.github]
app_id = "fixture-app-id"
@ -2365,13 +2400,9 @@ app_id = "fixture-app-id"
let prepared = prepare_manifest(&server_settings, &manifest).unwrap();
let settings_json = serde_json::to_value(&prepared.settings).unwrap();
// run.prepare.steps replaces the whole list across layers, so the
// higher-precedence workflow layer wins over cli.
assert_eq!(prepared.settings.run.prepare.steps.len(), 1);
assert_eq!(
prepared.settings.run.prepare.steps[0].to_shell_command(),
"workflow-setup"
);
assert_eq!(prepared.settings.run.checkpoint.exclude_globs, [
"workflow-cache/**"
]);
assert!(settings_json.pointer("/server").is_none());
}

View file

@ -149,7 +149,7 @@ stages live in the child invocation and list under the fork (see Parallel).
| files touched | `stage.completed` `files_touched` | Pebble's fold of envelope `ToolCallCompleted` (see Agent activity) | session |
| stage diff | `StageProjection.diff` | platform record `checkpoint {execution, firing, patch_blob}` | stage |
| artifacts | `RunArtifactEntry {stage_id, node_slug, retry, relative_path, size}`, the stage artifact endpoints | platform record `artifact.collected {execution, firing, attempt, path, object (or historical blob), bytes, digest}` from the `transition` hook, new bytes in configured artifact storage, historical blob sources in SQLite; a file unchanged since an earlier capture is not recorded again | attempt |
| checkout | `setup.*` lines, `attractor.checkout` | the root `start` stage's `custom attractor.checkout {repository, commit, depth, files}` and its log lines; `[run.prepare]` commands are `run_prepare_N` stages | stage |
| checkout | `setup.*` lines, `attractor.checkout` | the root `start` stage's `custom attractor.checkout {repository, commit, depth, files}` and its log lines; explicit setup commands are ordinary stages | stage |
| hook decisions | none today | `parsed.note {kind: hook}` (`HookReport`), `custom attractor.hook` (a point a step asks itself), `parsed.hook_activity`; `run.note.recorded` for run-level points | attempt |
| budget pause | none today | `parsed.budget {state, attempt, remaining_ms, pending_questions}` | attempt |
| nested workflow | `subgraph.started`, `subgraph.completed` | `invocation.declared` with `context.parent`, `invocation.finished {result}`, the child's `execution.declared` and `execution.finished` | invocation |
@ -219,7 +219,7 @@ which is Fabro's `local`; every other kind is spelled the same.
| where: instance id, working directory, clone, workspace roots | `RunSandboxInstance.runtime {id, working_directory, repo_cloned, clone_origin_url, clone_branch, workspace_root, repos_root, primary_repo_path, primary_repo_link}`, `sandbox.initialized` | `scope.acquired` (`instance` is the id a reconnect attaches by, `working_directory`). The clone fields stay unset: `custom attractor.checkout` records Petri's copy of the bound repository into the workspace (`repository`, `commit`, `depth`, `files`), which is not a clone Fabro made, and the workspace roots are the provider's layout, read live | scope |
| retention | `RunSandboxInstance.retained`; the run-end `sandbox_cleanup` hook | `scope.released {outcome, retained, problems}` of the root invocation's lease: `retained` is set on the instance (and kept as `FoldState.sandbox_retained`), and `Run.sandbox` keeps naming the instance that ran, whether or not it still exists. `run.note.recorded {kind: hook, point: scope_released}` when a hook ran | scope |
| live status, resources, files, services, VNC, preview, SSH | `SandboxStatus`, `SandboxFileEntry`, `SandboxService`, `VncPreviewResponse`, `PreviewUrlResponse`, `SshAccessResponse`, `ssh.ready` | live: the sandbox-driver provider queried by the run label | run |
| setup commands | `setup.started`, `setup.command.completed`, `setup.completed`, `setup.failed`, `cli.ensure.*` | the `run_prepare_N` stages (Stages section); `cli.ensure.*` has no Petri equivalent and is dropped (the image carries the CLI) | stage |
| setup commands | `setup.started`, `setup.command.completed`, `setup.completed`, `setup.failed`, `cli.ensure.*` | ordinary authored command stages (Stages section; historical `run_prepare_N` commands remain stages); `cli.ensure.*` has no Petri equivalent and is dropped (the image carries the CLI) | stage |
## Agent activity

View file

@ -240,21 +240,21 @@ mod tests {
}
#[test]
fn prepare_steps_are_stages_of_the_graph_they_run_in() {
fn explicit_setup_commands_are_stages_of_the_graph_they_run_in() {
let admitted = admit(&[
(
"workflow.fabro",
r#"digraph Prepared {
start [shape=Mdiamond]
setup [shape=parallelogram, script="make setup", timeout="5m", on_failure="exit"]
work [prompt="Work"]
exit [shape=Msquare]
start -> work -> exit
start -> setup -> work -> exit
}"#,
),
(
"workflow.toml",
"_version = 1\n[run]\ngoal = \"Settings goal\"\n[[run.prepare.steps]]\nscript = \
\"make setup\"\n",
"_version = 1\n[run]\ngoal = \"Settings goal\"\n",
),
]);
@ -262,12 +262,12 @@ mod tests {
assert_eq!(graph.goal(), "Settings goal");
assert_eq!(
graph.node("run_prepare_1").map(|node| node.kind),
graph.node("setup").map(|node| node.kind),
Some(StageHandler::Command)
);
assert_eq!(edges(&graph), vec![
("run_prepare_1", "work"),
("start", "run_prepare_1"),
("setup", "work"),
("start", "setup"),
("work", "exit"),
]);
}

View file

@ -482,3 +482,47 @@ async fn dry_run_admission_makes_nested_scopes_local_and_preserves_graph_digests
assert_eq!(loaded.graph, dry_run.graph);
assert_eq!(loaded.children, dry_run.children);
}
/// Keep this at the actual embedded frontend boundary: rejecting the key in
/// Fabro's config parser alone would still admit a stored old workflow version.
#[test]
fn removed_preparation_is_rejected_by_the_embedded_frontend_in_every_layer() {
for source in ["workflow.toml", ".fabro/project.toml", "settings.toml"] {
for syntax in [
"[run.prepare]",
"[run.prepare]\nsteps = []",
"run.prepare = false",
] {
let mut request = request(
bundle(&[
("workflow.fabro", COMMAND_WORKFLOW),
("workflow.toml", SETTINGS),
]),
RuntimeSpec::default(),
);
match source {
"settings.toml" => request.runtime.settings_toml = Some(syntax.into()),
".fabro/project.toml" => request.bundle.project_toml = Some(syntax.into()),
_ => {
request.bundle.files.insert(source.into(), syntax.into());
}
}
let error = check::check(&request)
.err()
.expect("removed preparation must not admit");
let CheckError::Rejected(diagnostics) = error else {
panic!("expected admission diagnostics")
};
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.code == "unsupported.workflow_toml.run.prepare"
&& diagnostic
.hint
.as_deref()
.is_some_and(|hint| hint.contains("command"))
}),
"{diagnostics:?}"
);
}
}
}

View file

@ -0,0 +1,16 @@
# Historical preparation admission
`graph.json` is the verbatim serialized graph admitted by Petri revision
`50809f9bd2847d20f047c3d5ffc6692fbc2f803d`, before removal of `run.prepare`.
`spec.json` wraps that graph reference and display graph in Fabro's saved RunSpec
shape, including the retired `settings.run.prepare` projection. It uses synthetic
identity and secret references only.
The original workflow was `start -> work -> exit`; preparation inserted
`run_prepare_1`. Its command writes `prepared` after checking a literal MODE
overlay and a vault TOKEN overlay, overriding both workflow environment keys.
The five-minute command timeout, failure policy, graph bytes, digest, and blob
hash are frozen. Stage-complete hooks record their node names in `stage-hooks`.
Do not regenerate these fixtures with a newer frontend: the tests load the
saved admission directly to exercise history, resume, retry and hook policy.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,170 @@
{
"run_id": "00000000000000000000000001",
"settings": {
"project": {
"name": null,
"description": null,
"metadata": {}
},
"workflow": {
"name": null,
"description": null,
"graph": "",
"metadata": {}
},
"run": {
"goal": null,
"working_dir": null,
"metadata": {},
"inputs": {},
"model": {
"provider": null,
"name": null,
"fallbacks": {},
"controls": {
"reasoning_effort": null,
"speed": null
}
},
"git": {
"author": null
},
"execution": {
"mode": "normal",
"approval": "prompt"
},
"checkpoint": {
"exclude_globs": [],
"skip_git_hooks": false,
"commit_timeout_ms": 30000
},
"clone": {
"enabled": true,
"depth": 100
},
"run_branch": {
"enabled": true,
"push": true
},
"environment": {
"id": "local",
"provider": "local",
"image": {
"docker": null,
"dockerfile": null
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"env": {
"MODE": "scope",
"TOKEN": "scope-token"
}
},
"notifications": {},
"interviews": {
"provider": null,
"slack": null
},
"agent": {
"fabro_tools": false,
"mcps": {}
},
"hooks": [],
"scm": {
"provider": null,
"owner": null,
"repository": null,
"github": null
},
"pull_request": null,
"artifacts": {
"include": []
},
"integrations": {
"github": {
"permissions": {}
}
},
"prepare": {
"steps": [
{
"type": "script",
"script": "test \"$MODE\" = step && test -n \"$TOKEN\" && echo prepared >> prepared",
"env": {
"MODE": "step",
"TOKEN": "{{ secrets.SETUP_TOKEN }}"
}
}
],
"timeout_ms": 300000
}
}
},
"graph": {
"name": "Prepared",
"goal": "",
"nodes": {
"exit": {
"label": "exit",
"kind": "exit"
},
"run_prepare_1": {
"label": "Prepare 1",
"kind": "command"
},
"start": {
"label": "start",
"kind": "start"
},
"work": {
"label": "work",
"kind": "command"
}
},
"edges": [
{
"from": "run_prepare_1",
"to": "work"
},
{
"from": "start",
"to": "run_prepare_1"
},
{
"from": "work",
"to": "exit"
}
]
},
"graph_source": "digraph Prepared {\n start [shape=Mdiamond]\n work [shape=parallelogram, script=\"test -f prepared\"]\n exit [shape=Msquare]\n start -> work -> exit\n }",
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
}
},
"admission": {
"graph": {
"blob": "0a98daf6b6a7d5e98fed9f136dcb026e47c551ae8627e45445fe6102c958d1fa",
"digest": "0a98daf6b6a7d5e98fed9f136dcb026e47c551ae8627e45445fe6102c958d1fa"
}
}
}

View file

@ -0,0 +1,347 @@
//! Frozen admission from Petri 50809f9: reading old settings must never
//! recompile removed syntax or strip the command's encoded environment overlay.
#![expect(
clippy::disallowed_methods,
reason = "test reads frozen fixtures and command effects"
)]
mod support;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use fabro_petri::admission::{self, AdmittedGraphs};
use fabro_petri::engine::{self, Execution, RunError, RunRequest, RunStatus};
use fabro_petri::runtime::RuntimeSpec;
use fabro_petri::secrets::VaultSecrets;
use fabro_store::{PlatformRecord, test_support};
use fabro_types::{BlobHash, RunId, RunProjection, RunSpec, SecretType};
use fabro_vault::Vault;
use fabro_workflow::operations::{self, ForkedRunInput};
use petri_store::{
Access, Digest, LogId, MemoryRunStore, Record, RunKey, RunLogs, RunStore, StoreError,
};
use support::{Silent, no_questions, run_request};
const SPEC: &str = include_str!("fixtures/historical-preparation/spec.json");
const GRAPH: &[u8] = include_bytes!("fixtures/historical-preparation/graph.json");
fn saved_spec() -> RunSpec {
serde_json::from_str(SPEC).expect("the historical spec remains readable")
}
async fn saved_graphs(spec: &RunSpec) -> AdmittedGraphs {
admission::load_with(
|hash| async move {
assert_eq!(hash, BlobHash::new(GRAPH));
Ok(Some(bytes::Bytes::from_static(GRAPH)))
},
&spec.admission,
)
.await
.expect("the saved graph keeps its digest")
}
async fn request(key: &str, root: &Path, store: Arc<dyn RunStore>) -> RunRequest {
let mut vault = Vault::from_entries(HashMap::new());
vault
.set(
"SETUP_TOKEN",
"historical-fixture-token",
SecretType::Token,
None,
)
.expect("the fixture vault accepts its token");
let mut request = run_request(
key,
root,
saved_graphs(&saved_spec()).await,
store,
RuntimeSpec::default(),
no_questions(Arc::new(Silent)),
);
request.secrets = Some(Arc::new(VaultSecrets::from_vault(&vault)));
request
}
fn workspace(root: &Path) -> PathBuf {
let scopes: Vec<_> = std::fs::read_dir(root.join("scopes"))
.expect("the run retains its scopes")
.map(|entry| entry.expect("a scope entry reads").path())
.collect();
assert_eq!(scopes.len(), 1);
scopes[0].join("work")
}
#[tokio::test]
async fn historical_spec_projection_retry_and_fork_preserve_the_saved_admission() {
let spec = saved_spec();
let original: serde_json::Value = serde_json::from_str(SPEC).unwrap();
assert!(original["settings"]["run"]["prepare"]["steps"].is_array());
let projected = serde_json::to_value(&spec).unwrap();
assert!(projected["settings"]["run"].get("prepare").is_none());
assert_eq!(projected["admission"], original["admission"]);
assert_eq!(projected["graph"], original["graph"]);
let graphs = saved_graphs(&spec).await;
assert_eq!(serde_json::to_vec(&graphs.graph).unwrap(), GRAPH);
let json = serde_json::to_value(&graphs.graph).unwrap();
let setup = json["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "run_prepare_1")
.unwrap();
assert_eq!(
setup["step"]["config"]["env"],
serde_json::json!({"MODE":"step","TOKEN":{"$secret":"SETUP_TOKEN"}})
);
let source = RunProjection::new("Historical setup".into(), spec.clone(), chrono::Utc::now());
let root = tempfile::tempdir().unwrap();
let forked = operations::forked_run_record(&ForkedRunInput {
source: &source,
new_run_id: RunId::new(),
run_dir: root.path().join("fork"),
checkpoint_sha: "fixture-checkpoint".into(),
provenance: None,
web_url: None,
retried_from: None,
});
assert_eq!(forked.spec.admission, spec.admission);
assert_eq!(forked.spec.graph, spec.graph);
saved_graphs(&forked.spec).await;
let database = test_support::test_database();
let retry_id = RunId::new();
Box::pin(operations::persist_retried_run(
&database,
&source,
retry_id,
&root.path().join("retry"),
spec.provenance.clone(),
None,
))
.await
.unwrap();
let records = database
.run_summary_store()
.platform_records()
.read(&retry_id)
.await
.unwrap();
let retry = records
.iter()
.find_map(|record| match &record.record {
PlatformRecord::RunCreated(created) => Some(&created.spec),
_ => None,
})
.unwrap();
assert_eq!(retry.admission, spec.admission);
assert_eq!(retry.graph, spec.graph);
saved_graphs(retry).await;
}
#[tokio::test]
async fn saved_preparation_executes_again_on_a_fresh_retry() {
let root = tempfile::tempdir().unwrap();
let store = Arc::new(MemoryRunStore::new());
for key in ["original", "retry"] {
let run_dir = root.path().join(key);
let outcome = engine::run(request(key, &run_dir, store.clone()).await)
.await
.unwrap();
assert_eq!(outcome.status, RunStatus::Success);
assert_eq!(
std::fs::read_to_string(workspace(&run_dir).join("prepared")).unwrap(),
"prepared\n"
);
}
}
#[tokio::test]
async fn saved_preparation_resumes_before_and_after_setup_without_duplicate_effects() {
// The frozen linear graph's firings are start=1, setup=2, work=3. Fail the
// durable start record before setup, then before work after setup completed.
for firing in [2, 3] {
let root = tempfile::tempdir().unwrap();
let memory = Arc::new(MemoryRunStore::new());
let failing = Arc::new(FailStart {
inner: memory.clone(),
firing,
failed: Arc::new(AtomicBool::new(false)),
});
let result = engine::run(request("resume", root.path(), failing.clone()).await).await;
assert!(matches!(result, Err(RunError::StoreFailed(_))));
assert!(failing.failed.load(Ordering::SeqCst));
if firing == 3 {
assert_eq!(
std::fs::read_to_string(workspace(root.path()).join("prepared")).unwrap(),
"prepared\n"
);
}
let mut resumed = request("resume", root.path(), memory.clone()).await;
let Execution::Start(graphs) = resumed.execution else {
unreachable!()
};
resumed.execution = Execution::Resume(graphs);
let outcome = engine::run(resumed).await.unwrap();
assert_eq!(outcome.status, RunStatus::Success);
assert_eq!(
std::fs::read_to_string(workspace(root.path()).join("prepared")).unwrap(),
"prepared\n"
);
}
}
#[tokio::test]
async fn saved_generated_command_receives_ordinary_hooks_when_it_executes() {
let root = tempfile::tempdir().unwrap();
let memory = Arc::new(MemoryRunStore::new());
let outcome = engine::run(request("hooks", root.path(), memory.clone()).await)
.await
.unwrap();
assert_eq!(outcome.status, RunStatus::Success);
let hooks = std::fs::read_to_string(workspace(root.path()).join("stage-hooks")).unwrap();
assert_eq!(
hooks
.lines()
.filter(|line| *line == "run_prepare_1")
.count(),
1
);
let mut resumed = request("hooks", root.path(), memory).await;
let Execution::Start(graphs) = resumed.execution else {
unreachable!()
};
resumed.execution = Execution::Resume(graphs);
assert_eq!(
engine::run(resumed).await.unwrap().status,
RunStatus::Success
);
assert_eq!(
std::fs::read_to_string(workspace(root.path()).join("stage-hooks")).unwrap(),
hooks
);
}
struct FailStart {
inner: Arc<MemoryRunStore>,
firing: u64,
failed: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl RunStore for FailStart {
async fn open(&self, key: &RunKey, access: Access) -> Result<Arc<dyn RunLogs>, StoreError> {
Ok(Arc::new(FailStartLogs {
inner: self.inner.open(key, access).await?,
firing: self.firing,
failed: self.failed.clone(),
}))
}
}
struct FailStartLogs {
inner: Arc<dyn RunLogs>,
firing: u64,
failed: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl RunLogs for FailStartLogs {
fn locator(&self) -> String {
self.inner.locator()
}
async fn append(&self, log: &LogId, records: &[Record]) -> Result<(), StoreError> {
if records.iter().any(|r| {
r.record["body"]["event"] == "step.started" && r.record["body"]["firing"] == self.firing
}) && !self.failed.swap(true, Ordering::SeqCst)
{
return Err(StoreError::backend(
self.locator(),
"append",
"injected crash before step start",
));
}
self.inner.append(log, records).await
}
async fn read(&self, log: &LogId) -> Result<Vec<Record>, StoreError> {
self.inner.read(log).await
}
async fn put_blob(&self, bytes: &[u8]) -> Result<Digest, StoreError> {
self.inner.put_blob(bytes).await
}
async fn get_blob(&self, digest: Digest) -> Result<Option<Vec<u8>>, StoreError> {
self.inner.get_blob(digest).await
}
}
#[tokio::test]
async fn saved_graph_forks_before_setup_and_executes_its_encoded_command() {
use petri_execution::host::{ForkOptions, ForkPosition};
use petri_execution::{host, inspect};
use petri_runtime::RunOptions;
use petri_runtime::ir::FiringId;
let root = tempfile::tempdir().unwrap();
let store = Arc::new(MemoryRunStore::new());
let source_dir = root.path().join("source");
let outcome = engine::run(request("source", &source_dir, store.clone()).await)
.await
.unwrap();
assert_eq!(outcome.status, RunStatus::Success);
let logs = store
.open(&RunKey::new("source"), Access::Read)
.await
.unwrap();
let inspection = inspect::inspect_run(&*logs).await.unwrap();
let execution = inspection
.executions
.iter()
.find(|execution| execution.engine.is_some())
.unwrap();
let start = execution
.engine
.as_ref()
.unwrap()
.history
.iter()
.find(|entry| entry.node == "start")
.unwrap();
let fork_dir = root.path().join("fork");
let mut options = RunOptions::new(&fork_dir);
options.run_key = Some(RunKey::new("fork"));
let runtime = RuntimeSpec::default()
.runtime(true)
.store(store.clone())
.options(options);
let forked = host::fork_from(
&runtime,
&*logs,
ForkPosition {
execution: execution.execution,
firing: FiringId(start.firing),
},
ForkOptions::default(),
)
.await
.unwrap();
assert_eq!(
forked.graph.to_string(),
saved_spec().admission.graph.digest
);
let mut resumed = request("fork", &fork_dir, store.clone()).await;
let Execution::Start(graphs) = resumed.execution else {
unreachable!()
};
resumed.execution = Execution::Resume(graphs);
let outcome = engine::run(resumed).await.unwrap();
assert_eq!(outcome.status, RunStatus::Success);
assert_eq!(
std::fs::read_to_string(workspace(&fork_dir).join("prepared")).unwrap(),
"prepared\n"
);
}

View file

@ -105,6 +105,42 @@ fn workflow_settings_default_run_checkpoint_skip_git_hooks_is_false() {
assert_eq!(json["run"]["checkpoint"]["skip_git_hooks"], false);
}
#[test]
fn current_settings_and_schema_omit_retired_preparation() {
let value = serde_json::to_value(WorkflowSettings::default()).unwrap();
assert!(value["run"].get("prepare").is_none());
let schema: serde_json::Value = serde_yaml::from_str(include_str!(
"../../../../docs/public/api-reference/fabro-api.yaml"
))
.unwrap();
let schemas = &schema["components"]["schemas"];
assert!(
schemas["RunNamespace"]["properties"]
.get("prepare")
.is_none()
);
assert!(
!schemas["RunNamespace"]["required"]
.as_array()
.unwrap()
.iter()
.any(|key| key == "prepare")
);
for name in [
"RunPrepareSettings",
"PreparedStep",
"PreparedScriptStep",
"PreparedCommandStep",
] {
assert!(schemas.get(name).is_none());
}
let mut historical = value.clone();
historical["run"]["prepare"] =
serde_json::json!({"commands": ["echo historical"], "timeout_ms": 300000});
let settings: ApiWorkflowSettings = serde_json::from_value(historical).unwrap();
assert_eq!(serde_json::to_value(settings).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),

View file

@ -9,9 +9,6 @@ graph = "workflow.fabro"
mode = "normal"
approval = "prompt"
[run.prepare]
timeout = "5m"
[run.clone]
enabled = true

View file

@ -14,8 +14,8 @@ use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::environment::EnvironmentDockerfileLayer;
use super::run::{
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice,
NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer,
RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer, ScmGitHubLayer,
StringOrSplice,
};
use super::server::{
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer,
@ -135,7 +135,6 @@ impl_combine_self!(
NotificationProviderLayer,
RunArtifactsLayer,
RunGoalLayer,
RunPrepareLayer,
ScmGitHubLayer,
ObjectStoreLocalLayer,
ObjectStoreS3Layer,

View file

@ -27,11 +27,10 @@ pub use project::ProjectLayer;
pub use run::{
GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer,
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer,
StringOrSplice,
NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer,
RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice,
};
pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,

View file

@ -32,8 +32,6 @@ pub struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<RunGitLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepare: Option<RunPrepareLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<RunExecutionLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<RunCheckpointLayer>,
@ -270,30 +268,6 @@ pub struct GitAuthorLayer {
pub email: Option<String>,
}
/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces
/// across layers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunPrepareLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<PrepareStep>,
/// Optional timeout applied to each prepare step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
}
/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrepareStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, InterpString>,
}
/// `[run.execution]` — run posture knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]

View file

@ -46,16 +46,15 @@ pub use layers::{
EnvironmentResourcesLayer, GitAuthorLayer, GithubIntegrationLayer, HookAgentMarker, HookEntry,
HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer,
LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer,
ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer,
RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer,
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, SlackIntegrationLayer,
StickyMap, StringOrSplice, WorkflowLayer,
NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ProjectLayer, ReplaceMap,
RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer,
RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer,
SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;

View file

@ -73,6 +73,42 @@ pub(crate) fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf
mod tests {
use super::*;
#[test]
fn rejects_removed_prepare_in_every_live_source_without_leaking_values() {
let dir = tempfile::tempdir().unwrap();
let forms = [
"[run.prepare]",
"[[run.prepare]]",
"[run.prepare]\ntimeout = \"5m\"",
"[run.prepare]\nsteps = []",
"run.prepare = 7",
"run = { prepare = {} }",
"run.prepare.steps = []",
"[\"run\".\"prepare\"]",
"[[run.prepare.steps]]\nscript = \"do-not-print-this-value\"",
];
for source in [
SettingsSource::ActiveSettings,
SettingsSource::Project,
SettingsSource::Workflow,
SettingsSource::DirectRun,
SettingsSource::User,
] {
let path = dir.path().join(format!("{source}.toml"));
for form in forms {
std::fs::write(&path, form).unwrap();
let error = load_settings_path(&path, source).unwrap_err();
let message = error.to_string();
assert!(message.contains("run.prepare"), "{message}");
assert!(message.contains("no longer supported"), "{message}");
assert!(message.contains("command nodes"), "{message}");
assert!(message.contains(&path.display().to_string()), "{message}");
assert!(!message.contains("do-not-print-this-value"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), form);
}
}
}
#[test]
fn load_settings_path_auto_migrates_legacy_sandbox_file() {
let dir = tempfile::tempdir().expect("temp dir");

View file

@ -31,6 +31,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[
pub enum ParseError {
Toml(String),
Version(VersionError),
RemovedRunPrepare,
UnknownTopLevelKey {
key: String,
hint: Option<String>,
@ -45,6 +46,9 @@ impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"),
Self::RemovedRunPrepare => f.write_str(
"`run.prepare` is no longer supported; move setup into ordinary command nodes after start, with explicit timeout and failure policy. See https://docs.fabro.sh/execution/run-configuration#setup-command-migration.",
),
Self::Version(err) => fmt::Display::fmt(err, f),
Self::UnknownTopLevelKey { key, hint } => {
if let Some(hint) = hint {
@ -89,7 +93,15 @@ impl fmt::Display for VersionError {
impl std::error::Error for VersionError {}
pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?;
let raw: toml::Value =
toml::from_str(input).map_err(|e| ParseError::Toml(e.message().to_owned()))?;
if raw
.get("run")
.and_then(toml::Value::as_table)
.is_some_and(|run| run.contains_key("prepare"))
{
return Err(ParseError::RemovedRunPrepare);
}
validate_version(&raw).map_err(ParseError::Version)?;
if let Some(table) = raw.as_table() {
@ -119,7 +131,7 @@ pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
}
raw.try_into::<SettingsLayer>()
.map_err(|e| ParseError::Toml(e.to_string()))
.map_err(|e| ParseError::Toml(e.message().to_owned()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -199,7 +211,7 @@ fn rename_hint(key: &str) -> Option<String> {
"labels" => "move to `[run.metadata]`",
"llm" => "rename to `[run.model]`",
"vars" => "rename to `[run.inputs]`",
"setup" => "rename to `[run.prepare]`",
"setup" => "move setup into ordinary command nodes after start",
"sandbox" => "rename to `[run.environment]` and `[environments.<slug>]`",
"checkpoint" => "move under `[run.checkpoint]`",
"pull_request" => "move under `[run.pull_request]`",
@ -234,6 +246,24 @@ fn rename_hint(key: &str) -> Option<String> {
mod tests {
use super::*;
#[test]
fn preparation_mentions_in_comments_and_script_strings_are_not_settings() {
let input = r#"# [run.prepare]
[[run.hooks]]
event = "run_start"
script = "echo '[run.prepare]'"
"#;
assert!(input.parse::<SettingsLayer>().is_ok());
}
#[test]
fn malformed_toml_diagnostics_do_not_echo_values() {
let error = "[run.prepare]\nscript = \"do-not-print-this-value"
.parse::<SettingsLayer>()
.unwrap_err();
assert!(!error.to_string().contains("do-not-print-this-value"));
}
#[test]
fn parses_empty_file() {
let file = "".parse::<SettingsLayer>().unwrap();

View file

@ -6,11 +6,11 @@ use fabro_types::settings::interp::ResolveCtx;
use fabro_types::settings::run::{
ArtifactsSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings,
McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings,
NotificationRouteSettings, PreparedStep, PreparedStepRun, PullRequestSettings,
ResolvedMcpEntry, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, RunCloneSettings,
RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings,
RunIntegrationsSettings, RunInterviewsSettings, RunModelControls, RunModelSettings,
RunNamespace, RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
NotificationRouteSettings, PullRequestSettings, ResolvedMcpEntry, RunAgentSettings,
RunBranchSettings, RunCheckpointSettings, RunCloneSettings, RunExecutionSettings,
RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings,
RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunScmSettings,
ScmGitHubSettings, TlsMode,
};
use fabro_util::workspace_glob::WorkspaceGlob;
@ -20,8 +20,8 @@ use crate::{
InterviewsLayer, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer,
RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer,
StickyMap, StringOrSplice,
RunLayer, RunModelLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, StickyMap,
StringOrSplice,
};
pub fn resolve_run(
@ -51,7 +51,6 @@ pub fn resolve_run(
inputs: layer.inputs.clone().unwrap_or_default(),
model: resolve_model(layer.model.as_ref()),
git: resolve_git(layer.git.as_ref()),
prepare: resolve_prepare(layer.prepare.as_ref(), errors),
execution: resolve_execution(layer.execution.as_ref()),
checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()),
clone,
@ -264,61 +263,6 @@ fn resolve_git(git: Option<&RunGitLayer>) -> RunGitSettings {
}
}
#[expect(
clippy::disallowed_methods,
reason = "intentional source preservation: prepare step commands and per-step env are carried \
in source form so `fabro validate` stays portable; secret tokens resolve and \
unsupported tokens fail at the run boundary in \
fabro_types::settings::run::RunPrepareSettings::resolve_step_secrets"
)]
fn resolve_prepare(
prepare: Option<&RunPrepareLayer>,
errors: &mut Vec<ResolveError>,
) -> RunPrepareSettings {
let prepare = prepare.expect("defaults.toml should provide run.prepare defaults");
let mut steps = Vec::new();
for (index, step) in prepare.steps.iter().enumerate() {
let run = match (&step.script, &step.command) {
// A `script` is a raw shell snippet. Carry it verbatim so the shell
// interprets it after late secret resolution.
(Some(script), None) => PreparedStepRun::Script {
script: script.as_source(),
},
// A `command` is an argv: carry it as a vector of element source
// strings — neither pre-joined nor shell-quoted here. Secret tokens
// resolve at the run boundary, and only the resolved value is
// shell-quoted (resolve-then-quote), so an interpolated value can
// never break out of its argument and inject shell syntax.
(None, Some(argv)) => PreparedStepRun::Command {
command: argv.iter().map(InterpString::as_source).collect(),
},
(Some(_), Some(_)) | (None, None) => {
errors.push(ResolveError::Invalid {
path: format!("run.prepare.steps[{index}]"),
reason: "exactly one of script or command must be set".to_string(),
});
continue;
}
};
steps.push(PreparedStep {
run,
env: step
.env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect(),
});
}
RunPrepareSettings {
steps,
timeout_ms: prepare
.timeout
.map_or(300_000, |timeout| timeout.as_millis()),
}
}
fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSettings {
let execution = execution.expect("defaults.toml should provide run.execution defaults");
@ -782,98 +726,3 @@ fn resolve_artifacts(
.unwrap_or_default(),
}
}
#[cfg(test)]
mod resolve_prepare_tests {
use std::collections::HashMap;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::PreparedStepRun;
use super::{ResolveError, resolve_prepare};
use crate::{PrepareStep, RunPrepareLayer};
fn resolve(layer: &RunPrepareLayer) -> Vec<super::PreparedStep> {
let mut errors: Vec<ResolveError> = Vec::new();
let settings = resolve_prepare(Some(layer), &mut errors);
assert!(errors.is_empty(), "unexpected resolve errors: {errors:?}");
settings.steps
}
#[test]
fn carries_per_step_env_through() {
let layer = RunPrepareLayer {
steps: vec![PrepareStep {
script: Some(InterpString::parse("setup")),
command: None,
env: HashMap::from([(
"TOKEN".to_string(),
InterpString::parse("{{ env.DEPLOY_TOKEN }}"),
)]),
}],
timeout: None,
};
let steps = resolve(&layer);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].run, PreparedStepRun::Script {
script: "setup".to_string(),
});
// The per-step env survives config resolution in source form. The
// unsupported token fails later at the run boundary.
assert_eq!(
steps[0].env.get("TOKEN").map(String::as_str),
Some("{{ env.DEPLOY_TOKEN }}")
);
}
#[test]
fn argv_command_elements_are_carried_in_source_form() {
let layer = RunPrepareLayer {
steps: vec![PrepareStep {
script: None,
command: Some(vec![
InterpString::parse("echo"),
InterpString::parse("hello world"),
InterpString::parse("{{ env.USER_INPUT }}"),
]),
env: HashMap::new(),
}],
timeout: None,
};
let steps = resolve(&layer);
// Argv elements are carried as separate source strings, NOT joined and
// NOT shell-quoted here. Quoting happens after late interpolation at
// the run boundary, so a resolved value (not the source token) is what
// gets quoted.
assert_eq!(steps[0].run, PreparedStepRun::Command {
command: vec![
"echo".to_string(),
"hello world".to_string(),
"{{ env.USER_INPUT }}".to_string(),
],
});
}
#[test]
fn script_is_kept_verbatim() {
let layer = RunPrepareLayer {
steps: vec![PrepareStep {
script: Some(InterpString::parse("echo hello && ls -la")),
command: None,
env: HashMap::new(),
}],
timeout: None,
};
let steps = resolve(&layer);
// A script is a raw shell snippet, not an argv: it is carried verbatim.
assert_eq!(steps[0].run, PreparedStepRun::Script {
script: "echo hello && ls -la".to_string(),
});
}
}

View file

@ -74,28 +74,6 @@ C = "higher-c"
);
}
#[test]
fn run_prepare_steps_replaces_whole_list() {
let lower = parse(
r#"
[[run.prepare.steps]]
script = "lower-1"
[[run.prepare.steps]]
script = "lower-2"
"#,
);
let higher = parse(
r#"
[[run.prepare.steps]]
script = "higher-1"
"#,
);
let merged = higher.combine(lower);
let steps = merged.run.unwrap().prepare.unwrap().steps;
assert_eq!(steps.len(), 1);
}
#[test]
fn run_model_fallbacks_splice_inserts_inherited() {
let lower = parse(

View file

@ -202,9 +202,8 @@ _version = 1
[run.environment]
id = "bad"
[[run.prepare.steps]]
script = "echo hi"
command = ["echo", "hi"]
[run.artifacts]
include = ["/absolute/path"]
"#,
r#"
[environments.bad]
@ -218,5 +217,5 @@ provider = "Not A Provider"
.to_string();
assert!(rendered.contains("run.environment.provider"));
assert!(rendered.contains("run.prepare.steps[0]"));
assert!(rendered.contains("run.artifacts.include[0]"));
}

View file

@ -106,7 +106,6 @@ fn resolves_run_defaults_from_empty_settings() {
assert_eq!(settings.execution.mode, RunMode::Normal);
assert_eq!(settings.execution.approval, ApprovalMode::Prompt);
assert_eq!(settings.prepare.timeout_ms, 300_000);
assert_eq!(settings.environment.id, "default");
assert_eq!(settings.environment.provider, SandboxProviderKind::DOCKER);
assert_eq!(

View file

@ -38,11 +38,10 @@ pub use run::{
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentResourcesSettings,
EnvironmentSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings,
McpServerRef, McpServerSettings, McpTransport, NotificationProviderSettings,
NotificationRouteSettings, PreparedStep, PullRequestSettings, ResolvedMcpEntry,
RunAgentSettings, RunCheckpointSettings, RunEnvironmentSettings, RunExecutionSettings,
RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings,
RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings,
RunScmSettings, ScmGitHubSettings, TlsMode,
NotificationRouteSettings, PullRequestSettings, ResolvedMcpEntry, RunAgentSettings,
RunCheckpointSettings, RunEnvironmentSettings, RunExecutionSettings, RunGitSettings, RunGoal,
RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings,
RunModelControls, RunModelSettings, RunNamespace, RunScmSettings, ScmGitHubSettings, TlsMode,
};
pub use server::{
GithubIntegrationSettings, IntegrationWebhooksSettings, LogDestination, ObjectStoreSettings,

View file

@ -2,7 +2,7 @@
//!
//! `[run]` is the shared execution domain. It may appear in all three config
//! files and layer normally. Subdomains cover model selection, git author,
//! prepare steps, execution posture, checkpoint policy, environment selection,
//! execution posture, checkpoint policy, environment selection,
//! notifications, interviews, agent knobs, hooks, SCM targeting, pull-request
//! behavior, and artifact collection.
@ -10,7 +10,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration as StdDuration;
use fabro_util::shell;
use serde::de::{self, Deserializer};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
@ -31,7 +30,6 @@ pub struct RunNamespace {
pub inputs: HashMap<String, toml::Value>,
pub model: RunModelSettings,
pub git: RunGitSettings,
pub prepare: RunPrepareSettings,
pub execution: RunExecutionSettings,
pub checkpoint: RunCheckpointSettings,
pub clone: RunCloneSettings,
@ -60,7 +58,6 @@ impl Default for RunNamespace {
inputs: HashMap::new(),
model: RunModelSettings::default(),
git: RunGitSettings::default(),
prepare: RunPrepareSettings::default(),
execution: RunExecutionSettings::default(),
checkpoint: RunCheckpointSettings::default(),
clone: RunCloneSettings::default(),
@ -86,7 +83,7 @@ impl RunNamespace {
substitute_goal(&mut self.goal, &mut lookup)?;
substitute_string_map(&mut self.metadata, &mut lookup)?;
// run.working_dir, run.model.provider/name, and run.git.author.* were
// demoted to plain `String` and removed from this pass (D2/D11):
// plain `String` fields excluded from this pass:
// values stay literal.
substitute_option_string(&mut self.model.controls.reasoning_effort, &mut lookup)?;
substitute_option_string(&mut self.model.controls.speed, &mut lookup)?;
@ -106,9 +103,6 @@ impl RunNamespace {
}
substitute_map(&mut self.integrations.github.permissions, &mut lookup)?;
// run.scm.owner/repository are plain strings: values stay literal.
for step in &mut self.prepare.steps {
visit_prepared_step_strings(step, &mut |value| substitute_string(value, &mut lookup))?;
}
// Only resolved inline servers carry substitutable templates; an
// unresolved reference holds just an id + enabled flag.
for entry in self.agent.mcps.values_mut() {
@ -264,28 +258,6 @@ where
}
}
/// Walk every interpolatable string in one prepare step — the runnable part
/// (a `script` snippet or each `command` argv element) and every per-step `env`
/// value. Both interpolation passes route through this one traversal so they
/// cannot drift as `PreparedStepRun` or `PreparedStep` grow fields: the
/// `{{ vars.* }}` pass ([`RunNamespace::substitute_variables`]) passes a
/// `substitute_string` visitor, and the late secret pass
/// ([`RunPrepareSettings::resolve_step_secrets`]) passes a
/// `resolve_secret_string` visitor. Mirrors [`visit_mcp_transport_strings`].
fn visit_prepared_step_strings<F>(
step: &mut PreparedStep,
visitor: &mut F,
) -> Result<(), ResolveError>
where
F: FnMut(&mut String) -> Result<(), ResolveError>,
{
match &mut step.run {
PreparedStepRun::Script { script } => visitor(script)?,
PreparedStepRun::Command { command } => visit_string_vec(command, visitor)?,
}
visit_string_map(&mut step.env, visitor)
}
fn visit_string_vec<F>(values: &mut [String], visitor: &mut F) -> Result<(), ResolveError>
where
F: FnMut(&mut String) -> Result<(), ResolveError>,
@ -366,8 +338,8 @@ mod run_namespace_variable_substitution_tests {
use super::{
ArtifactsSettings, DockerfileSource, EnvironmentImageSettings, EnvironmentNetworkMode,
EnvironmentNetworkSettings, HookDefinition, HookEvent, HookType, InterpString,
McpHttpProtocol, McpServerSettings, McpTransport, PreparedStepRun, RunCheckpointSettings,
RunEnvironmentSettings, RunGoal, RunNamespace, RunPrepareSettings,
McpHttpProtocol, McpServerSettings, McpTransport, RunCheckpointSettings,
RunEnvironmentSettings, RunGoal, RunNamespace,
};
#[expect(
@ -380,22 +352,6 @@ mod run_namespace_variable_substitution_tests {
goal: Some(RunGoal::Inline(InterpString::parse(
"deploy {{ vars.ENV }} in {{ env.REGION }}",
))),
prepare: RunPrepareSettings {
steps: vec![super::PreparedStep {
run: PreparedStepRun::Command {
command: vec![
"echo".to_string(),
"{{ vars.ENV }}".to_string(),
"{{ env.REGION }}".to_string(),
],
},
env: HashMap::from([(
"STAGE".to_string(),
"{{ vars.ENV }}-{{ env.REGION }}".to_string(),
)]),
}],
timeout_ms: 1_000,
},
agent: super::RunAgentSettings {
mcps: HashMap::from([(
"http".to_string(),
@ -452,21 +408,6 @@ mod run_namespace_variable_substitution_tests {
goal_source,
Some("deploy prod in {{ env.REGION }}".to_string())
);
assert_eq!(run.prepare.steps.len(), 1);
// `{{ vars.* }}` substitutes per argv element while `{{ env.* }}` is
// left for the run boundary.
let PreparedStepRun::Command { command } = &run.prepare.steps[0].run else {
panic!("expected command argv prepare step");
};
assert_eq!(command.as_slice(), [
"echo".to_string(),
"prod".to_string(),
"{{ env.REGION }}".to_string(),
]);
assert_eq!(
run.prepare.steps[0].env.get("STAGE").map(String::as_str),
Some("prod-{{ env.REGION }}")
);
let mcp = run.agent.mcps["http"]
.as_resolved()
.expect("expected resolved inline mcp entry");
@ -911,114 +852,6 @@ pub struct GitAuthorSettings {
pub email: Option<String>,
}
// `#[serde(default)]` at the container level: these settings are persisted
// inside the `run.created` event, so they must stay readable for events written
// by older fabro versions. `steps` replaced a `commands: Vec<String>` field in
// #530, so runs created before that have a `prepare` object with no `steps`
// key. Without a default, the whole run fails to deserialize during projection
// cache warmup and silently disappears from the run list. Falling back to the
// `Default` impl (empty steps, product-default timeout) keeps historical runs
// loadable; new runs always serialize explicit values, so nothing changes for
// them.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunPrepareSettings {
pub steps: Vec<PreparedStep>,
pub timeout_ms: u64,
}
impl Default for RunPrepareSettings {
fn default() -> Self {
Self {
steps: Vec::new(),
timeout_ms: 300_000,
}
}
}
impl RunPrepareSettings {
/// Resolve `{{ secrets.* }}` tokens in every prepare step's runnable part
/// and per-step `env` values against the supplied lookup. Unsupported
/// tokens fail instead of reaching the process. A `script` step's snippet
/// resolves in place; a `command` step's argv resolves per element (each
/// element is shell-quoted later, in [`PreparedStep::to_shell_command`], so
/// quoting applies to the resolved value rather than the source token).
///
/// This is the late, use-time half of prepare-step interpolation, the
/// counterpart to the server-side `{{ vars.* }}` substitution in
/// [`RunNamespace::substitute_variables`]: `{{ vars.* }}` are substituted
/// earlier, server-side, while `{{ secrets.* }}` resolves in whichever
/// process actually runs the steps (the run worker for `fabro run`).
/// Carrying the source form out of the config resolve layer keeps
/// `fabro validate` portable.
///
/// A missing or non-token secret is a hard error. Unsupported `env` and
/// template-only `inputs` tokens surface as
/// [`ResolveErrorKind::Unavailable`](super::interp::ResolveErrorKind::Unavailable) errors.
pub fn resolve_step_secrets(
&self,
mut secrets_lookup: impl FnMut(&str) -> Option<String>,
) -> Result<Self, ResolveError> {
let mut resolved = self.clone();
for step in &mut resolved.steps {
visit_prepared_step_strings(step, &mut |value| {
resolve_secret_string(value, &mut secrets_lookup)
})?;
}
Ok(resolved)
}
}
/// A single resolved prepare step: the thing to run plus the per-step
/// environment variables it should see. The runnable part keeps the
/// script-vs-argv distinction (see [`PreparedStepRun`]), and every string is
/// carried in source form out of the config resolve layer. Secret tokens
/// resolve at the run boundary via
/// [`RunPrepareSettings::resolve_step_secrets`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PreparedStep {
#[serde(flatten)]
pub run: PreparedStepRun,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
}
/// The runnable part of a prepare step, preserving the script-vs-argv
/// distinction so each is treated correctly when assembled into the shell
/// command that runs via `bash -c`:
///
/// - [`Script`](PreparedStepRun::Script) is a raw shell snippet kept verbatim
/// for the shell to interpret.
/// - [`Command`](PreparedStepRun::Command) is an argv: a vector of element
/// source strings, neither pre-joined nor shell-quoted at config time. Its
/// `{{ secrets.* }}` tokens resolve per element at the run boundary, and only
/// then is each resolved element shell-quoted and joined. Resolving before
/// quoting stops an interpolated secret from breaking out of its argument and
/// injecting shell syntax.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PreparedStepRun {
Script { script: String },
Command { command: Vec<String> },
}
impl PreparedStep {
/// Flatten this step's runnable part into the single shell string that runs
/// via `bash -c`.
///
/// For a script, the snippet is returned verbatim. For an argv `command`,
/// each element is shell-quoted and joined with spaces so an argument that
/// contains spaces or shell metacharacters survives as a single token. This
/// must run *after* [`RunPrepareSettings::resolve_step_secrets`] so the
/// quoting applies to the resolved values, not the secret token source.
pub fn to_shell_command(&self) -> String {
match &self.run {
PreparedStepRun::Script { script } => script.clone(),
PreparedStepRun::Command { command } => shell::shell_join(command),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunExecutionSettings {
pub mode: RunMode,
@ -1315,7 +1148,7 @@ impl Default for RunEnvironmentSettings {
}
/// Build a lookup closure over a fixed list of name/value pairs for the
/// run-boundary resolver tests. Shared by the env, secret, prepare-step, and
/// run-boundary resolver tests. Shared by the env, secret, and
/// MCP transport test modules.
#[cfg(test)]
fn pair_lookup(
@ -1962,283 +1795,6 @@ mod resolve_transport_secrets_tests {
}
}
#[cfg(test)]
mod resolve_step_secrets_tests {
use std::collections::HashMap;
use super::super::interp::ResolveErrorKind;
use super::{
Namespace, PreparedStep, PreparedStepRun, RunPrepareSettings, pair_lookup as secret_lookup,
};
fn script_step(script: &str, env: HashMap<String, String>) -> PreparedStep {
PreparedStep {
run: PreparedStepRun::Script {
script: script.to_string(),
},
env,
}
}
fn command_step(argv: &[&str], env: HashMap<String, String>) -> PreparedStep {
PreparedStep {
run: PreparedStepRun::Command {
command: argv.iter().map(|element| (*element).to_string()).collect(),
},
env,
}
}
// Regression for the projection-cache-warmup drop: runs created before #530
// persisted `prepare` inside the `run.created` event as
// `{ "commands": [...], "timeout_ms": N }` — no `steps` key. Those old
// events must still deserialize (as an empty prepare phase) instead of
// failing the whole run's projection and silently vanishing from the run
// list.
#[test]
fn deserializes_pre_530_prepare_without_steps() {
let old = serde_json::json!({
"commands": ["echo build", "echo test"],
"timeout_ms": 60_000,
});
let settings: RunPrepareSettings = serde_json::from_value(old).unwrap();
assert!(settings.steps.is_empty());
assert_eq!(settings.timeout_ms, 60_000);
}
// Any absent field falls back to the product default, so no missing field
// can ever hide a run.
#[test]
fn deserializes_prepare_missing_every_field() {
let settings: RunPrepareSettings = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(settings, RunPrepareSettings::default());
}
#[test]
fn literal_step_passes_through() {
let settings = RunPrepareSettings {
steps: vec![script_step(
"echo hello",
HashMap::from([("STAGE".to_string(), "build".to_string())]),
)],
timeout_ms: 1_000,
};
let resolved = settings.resolve_step_secrets(secret_lookup(&[])).unwrap();
assert_eq!(resolved.steps[0].to_shell_command(), "echo hello");
assert_eq!(
resolved.steps[0].env.get("STAGE").map(String::as_str),
Some("build")
);
}
#[test]
fn script_resolves_verbatim() {
// A script is a raw shell snippet: its token resolves but the result is
// NOT shell-quoted — the shell interprets the snippet as written.
let settings = RunPrepareSettings {
steps: vec![script_step(
"deploy {{ secrets.REGION }} && echo done",
HashMap::new(),
)],
timeout_ms: 1_000,
};
let resolved = settings
.resolve_step_secrets(secret_lookup(&[("REGION", "us-east-1")]))
.unwrap();
assert_eq!(
resolved.steps[0].to_shell_command(),
"deploy us-east-1 && echo done"
);
}
#[test]
fn command_and_env_resolve_secret_tokens() {
let settings = RunPrepareSettings {
steps: vec![command_step(
&["deploy", "{{ secrets.REGION }}"],
HashMap::from([(
"TOKEN".to_string(),
"{{ secrets.DEPLOY_TOKEN }}".to_string(),
)]),
)],
timeout_ms: 1_000,
};
let resolved = settings
.resolve_step_secrets(secret_lookup(&[
("REGION", "us-east-1"),
("DEPLOY_TOKEN", "secret-token"),
]))
.unwrap();
assert_eq!(resolved.steps[0].to_shell_command(), "deploy us-east-1");
assert_eq!(
resolved.steps[0].env.get("TOKEN").map(String::as_str),
Some("secret-token")
);
}
#[test]
fn command_arg_with_space_stays_one_token() {
// A resolved argv element that contains a space must survive as a
// single shell word, not re-split into two.
let settings = RunPrepareSettings {
steps: vec![command_step(
&["echo", "{{ secrets.MESSAGE }}"],
HashMap::new(),
)],
timeout_ms: 1_000,
};
let resolved = settings
.resolve_step_secrets(secret_lookup(&[("MESSAGE", "hello world")]))
.unwrap();
let shell = resolved.steps[0].to_shell_command();
let tokens = shlex::split(&shell).expect("resolved command should be valid shell");
assert_eq!(tokens, vec!["echo".to_string(), "hello world".to_string()]);
}
#[test]
fn command_arg_resolving_to_shell_metacharacters_is_not_injected() {
// Regression test for the command-injection defect: an interpolated
// value containing a single quote and `;` must be resolved THEN quoted
// so it stays a single argument and cannot break out to inject extra
// shell commands. Quoting the source token *before* resolving (the old
// behavior) lets the substituted value escape its quotes.
let malicious = "x'; touch PWNED; echo '";
let settings = RunPrepareSettings {
steps: vec![command_step(
&["echo", "{{ secrets.USER_INPUT }}"],
HashMap::new(),
)],
timeout_ms: 1_000,
};
let resolved = settings
.resolve_step_secrets(|name| (name == "USER_INPUT").then(|| malicious.to_string()))
.unwrap();
let shell = resolved.steps[0].to_shell_command();
// The flattened shell string round-trips to EXACTLY two tokens: the
// command and the verbatim payload as a single argument. Pre-fix, the
// value was substituted raw inside config-time quotes
// (`echo 'x'; touch PWNED; echo ''`), which `shlex::split` parses as
// several tokens / an injected `touch PWNED` command — so the round-trip
// equality below fails on the buggy code and passes once the value is
// resolved THEN quoted.
let tokens = shlex::split(&shell).expect("resolved command should be valid shell");
assert_eq!(tokens, vec!["echo".to_string(), malicious.to_string()]);
assert_eq!(
tokens.len(),
2,
"injected shell syntax leaked extra tokens: {shell}"
);
}
#[test]
fn missing_secret_in_command_is_hard_error() {
let settings = RunPrepareSettings {
steps: vec![command_step(
&["deploy", "{{ secrets.REGION }}"],
HashMap::new(),
)],
timeout_ms: 1_000,
};
let err = settings
.resolve_step_secrets(secret_lookup(&[]))
.unwrap_err();
assert_eq!(err.namespace, Namespace::Secrets);
assert_eq!(err.name, "REGION");
assert_eq!(err.kind, ResolveErrorKind::Missing);
}
#[test]
fn missing_secret_in_step_env_value_is_hard_error() {
let settings = RunPrepareSettings {
steps: vec![script_step(
"echo hi",
HashMap::from([(
"TOKEN".to_string(),
"{{ secrets.DEPLOY_TOKEN }}".to_string(),
)]),
)],
timeout_ms: 1_000,
};
let err = settings
.resolve_step_secrets(secret_lookup(&[]))
.unwrap_err();
assert_eq!(err.namespace, Namespace::Secrets);
assert_eq!(err.name, "DEPLOY_TOKEN");
assert_eq!(err.kind, ResolveErrorKind::Missing);
}
#[test]
fn script_command_and_env_resolve_secret_tokens() {
let settings = RunPrepareSettings {
steps: vec![
script_step(
"deploy {{ secrets.REGION }} && echo done",
HashMap::from([(
"TOKEN".to_string(),
"{{ secrets.DEPLOY_TOKEN }}".to_string(),
)]),
),
command_step(&["notify", "{{ secrets.MESSAGE }}"], HashMap::new()),
],
timeout_ms: 1_000,
};
let resolved = settings
.resolve_step_secrets(secret_lookup(&[
("REGION", "us-east-1"),
("DEPLOY_TOKEN", "vault-token"),
("MESSAGE", "hello world"),
]))
.unwrap();
assert_eq!(
resolved.steps[0].to_shell_command(),
"deploy us-east-1 && echo done"
);
assert_eq!(
resolved.steps[0].env.get("TOKEN").map(String::as_str),
Some("vault-token")
);
assert_eq!(resolved.steps[1].to_shell_command(), "notify 'hello world'");
}
#[test]
fn missing_secret_token_is_secret_error() {
let settings = RunPrepareSettings {
steps: vec![script_step(
"echo hi",
HashMap::from([("API_KEY".to_string(), "{{ secrets.API_KEY }}".to_string())]),
)],
timeout_ms: 1_000,
};
let err = settings
.resolve_step_secrets(secret_lookup(&[]))
.unwrap_err();
assert_eq!(err.namespace, Namespace::Secrets);
assert_eq!(err.name, "API_KEY");
assert_eq!(err.kind, ResolveErrorKind::Missing);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpTransport {
@ -2465,18 +2021,6 @@ pub enum ResolvedGoalSource {
File { path: std::path::PathBuf },
}
/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrepareStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, InterpString>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {

View file

@ -312,9 +312,6 @@ models/preflight-check-result.ts
models/preflight-check-section.ts
models/preflight-response.ts
models/preflight-workflow-summary.ts
models/prepared-command-step.ts
models/prepared-script-step.ts
models/prepared-step.ts
models/preview-url-request.ts
models/preview-url-response.ts
models/principal-agent.ts
@ -418,7 +415,6 @@ models/run-model.ts
models/run-namespace.ts
models/run-origin.ts
models/run-pair-status-response.ts
models/run-prepare-settings.ts
models/run-projection.ts
models/run-provenance.ts
models/run-question.ts

View file

@ -282,9 +282,6 @@ export * from './preflight-check-result';
export * from './preflight-check-section';
export * from './preflight-response';
export * from './preflight-workflow-summary';
export * from './prepared-command-step';
export * from './prepared-script-step';
export * from './prepared-step';
export * from './preview-url-request';
export * from './preview-url-response';
export * from './principal';
@ -389,7 +386,6 @@ export * from './run-model-settings';
export * from './run-namespace';
export * from './run-origin';
export * from './run-pair-status-response';
export * from './run-prepare-settings';
export * from './run-projection';
export * from './run-provenance';
export * from './run-question';

View file

@ -1,27 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PreparedCommandStep {
'type': PreparedCommandStepTypeEnum;
'command': Array<string>;
'env'?: { [key: string]: string; };
}
export const PreparedCommandStepTypeEnum = {
COMMAND: 'command'
} as const;
export type PreparedCommandStepTypeEnum = typeof PreparedCommandStepTypeEnum[keyof typeof PreparedCommandStepTypeEnum];

View file

@ -1,27 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PreparedScriptStep {
'type': PreparedScriptStepTypeEnum;
'script': string;
'env'?: { [key: string]: string; };
}
export const PreparedScriptStepTypeEnum = {
SCRIPT: 'script'
} as const;
export type PreparedScriptStepTypeEnum = typeof PreparedScriptStepTypeEnum[keyof typeof PreparedScriptStepTypeEnum];

View file

@ -1,27 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { PreparedCommandStep } from './prepared-command-step';
// May contain unused imports in some cases
// @ts-ignore
import type { PreparedScriptStep } from './prepared-script-step';
/**
* @type PreparedStep
* A single resolved prepare step. The runnable part preserves the script-vs-argv distinction via the `type` discriminator: a `script` is a raw shell snippet kept verbatim, while a `command` is an argv whose elements are shell-quoted and joined at the run boundary (after `{{ secrets.* }}` resolution) so an interpolated value cannot inject shell syntax. Optional per-step `env` is shared by both shapes.
*/
export type PreparedStep = { type: 'command' } & PreparedCommandStep | { type: 'script' } & PreparedScriptStep;

View file

@ -21,7 +21,7 @@ import type { RunGraphEdge } from './run-graph-edge';
import type { RunGraphNode } from './run-graph-node';
/**
* The display graph of a run: the admitted workflow\'s name, goal, stages and edges, read off the graph Petri admitted at create time. Lowering artifacts (the goal check, a synthetic fan-in) are left out; the stages are the nodes the workflow declares, imports and `[run.prepare]` steps included.
* The display graph of a run: the admitted workflow\'s name, goal, stages and edges, read off the graph Petri admitted at create time. Lowering artifacts (the goal check, a synthetic fan-in) are left out; the stages are the nodes the workflow declares, including imported nodes.
*/
export interface RunGraph {
/**

View file

@ -60,9 +60,6 @@ import type { RunInterviewsSettings } from './run-interviews-settings';
import type { RunModelSettings } from './run-model-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunPrepareSettings } from './run-prepare-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunScmSettings } from './run-scm-settings';
export interface RunNamespace {
@ -75,7 +72,6 @@ export interface RunNamespace {
'inputs': { [key: string]: any; };
'model': RunModelSettings;
'git': RunGitSettings;
'prepare': RunPrepareSettings;
'execution': RunExecutionSettings;
'checkpoint': RunCheckpointSettings;
'clone': RunCloneSettings;

View file

@ -1,23 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { PreparedStep } from './prepared-step';
export interface RunPrepareSettings {
'steps': Array<PreparedStep>;
'timeout_ms': number;
}