Create CLI runs from immutable workflow intents

This commit is contained in:
Scott Werner 2026-08-31 17:45:31 -04:00
parent d260ae89b5
commit 2507a0075f
22 changed files with 1939 additions and 243 deletions

View file

@ -0,0 +1,31 @@
---
title: "Immutable workflow versions for CLI runs"
date: "2026-08-31"
---
`fabro run` and `fabro create` now validate local workflows, register their
immutable workflow versions and dependencies, and create runs by intent.
`fabro create` leaves the run submitted; `fabro run` starts it with a separate
request.
Workflows can still be selected by project name, from user workflow storage,
from another local checkout, or as loose local files. The workflow source is
independent from the execution target: local environments use the directory
where Fabro was invoked, while Docker and Daytona derive a GitHub target from
that directory. Remote Git workflow acquisition is not included.
CLI input overrides retain string, Boolean, integer, and finite-float types,
and Boolean flags remain sparse. `--goal-file` is read locally and sent by
value as a per-run override; goal files referenced by `workflow.toml` remain
part of the immutable workflow.
Project and CLI-machine `[run]` and `[environments]` settings are no longer
transmitted during intent creation. Fabro warns with only the affected file and
key names. Move workflow behavior—including `[run.pull_request]`—to
`workflow.toml`, and configure placement with server-managed environments.
Malformed or unreadable active configuration still fails before creation.
New repositories now place their default automatic pull-request policy in the
starter workflow rather than `.fabro/project.toml`. Intent-created runs also
derive their display slug from the immutable workflow entrypoint. These
changes require no API schema update.

View file

@ -3,12 +3,29 @@ title: "Run Configuration"
description: "Configure workflow runs with TOML files"
---
A run config is a TOML file that bundles a workflow graph with all the settings needed to execute it — the goal, model, sandbox, prepare steps, inputs, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command:
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
workflow-owned settings. Instead of passing a dozen CLI flags, check
`workflow.toml` into version control and launch it with a single command:
```bash
fabro run run.toml
```
`fabro run` and `fabro create` resolve the workflow locally, validate it,
register its immutable workflow version and dependencies, and then ask the
server to create a run from that version. `fabro create` stops with the run in
the submitted state; `fabro run` performs the same create operation and then
starts the run separately.
The workflow can be selected by name from the current project or user workflow
storage, by a path in another local checkout, or as a loose local file. Its
source location does not choose the execution workspace: the directory where
you invoke Fabro remains the target source. Clone-based environments derive a
GitHub target from that caller directory, while a local environment receives
the canonical caller directory directly. Fetching workflow definitions from a
remote Git URL is not part of these commands.
## Minimal example
A run config needs at minimum a schema version and a goal:
@ -29,7 +46,10 @@ goal = "Implement the login feature"
| `[workflow].graph` | No | Path to the Graphviz workflow file, relative to the TOML file's directory. Defaults to `workflow.fabro`. |
| `[run].goal` | No | What the workflow should accomplish. Passed to agents and available via `--goal` CLI flag or Graphviz graph `goal` attribute. |
Goal precedence: CLI `--goal` > `[run].goal` > Graphviz graph attribute.
Goal precedence: CLI `--goal` or `--goal-file` > `[run].goal` > Graphviz graph
attribute. A CLI `--goal-file` is read on the invoking machine and sent as a
per-run value; a goal file referenced by `workflow.toml` remains immutable
workflow content.
## Full example
@ -58,32 +78,6 @@ script = "git clone https://github.com/fabro-sh/fabro repo"
[[run.prepare.steps]]
script = "cd repo && npm install"
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.lifecycle]
preserve = false
auto_stop = "60m"
[environments.cloud.labels]
project = "fabro"
env = "ci"
[environments.cloud.image]
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[environments.cloud.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
[environments.cloud.env]
API_KEY = "{{ secrets.MY_API_KEY }}"
NODE_ENV = "production"
[run.integrations.github.permissions]
contents = "write"
pull_requests = "write"
@ -281,15 +275,16 @@ push = true
| `enabled` | When `false`, Fabro skips metadata branch snapshots. |
| `push` | When `false`, Fabro writes metadata snapshots locally but does not push `fabro/meta/<id>` to the remote. |
### `[run.environment]` and `[environments.<slug>]`
### `[run.environment]` and server-managed environments
Runs select a reusable named environment by slug. Environment catalogs can be
defined in `settings.toml`, `.fabro/project.toml`, or `workflow.toml`.
```toml title="run.toml"
[run.environment]
id = "ci"
Runs select a reusable server-managed environment by slug. For `fabro run` and
`fabro create`, use `--environment <slug>` to select it; omitting the flag
selects `default`. Configure the catalog on the server rather than relying on
the CLI machine's `settings.toml` or the source checkout's
`.fabro/project.toml`, because those `environments` tables are not transmitted
during intent creation.
```toml title="server settings.toml"
[environments.ci]
provider = "docker" # local | docker | daytona
@ -308,8 +303,8 @@ stop_on_terminal = true
NODE_ENV = "production"
```
Sparse run-level overrides live under `[run.environment.*]` and apply to the
selected environment only:
Workflow-owned sparse overrides can live under `[run.environment.*]` and apply
to the selected server environment:
```toml
[run.environment.resources]
@ -367,7 +362,9 @@ issues = "read"
Only requested permissions are included. The upper bound is the permission set granted to the installed GitHub App, and Fabro logs a warning and continues without `GITHUB_TOKEN` if the app is not configured or is not installed on the repository.
This table follows the normal settings precedence order. A higher-precedence layer can set `permissions = {}` to clear inherited permissions and run without a GitHub token.
This table follows the workflow settings merge rules. A higher-precedence
workflow or CLI override can set `permissions = {}` to clear inherited
permissions and run without a GitHub token.
### `[run.integrations.github].additional_repositories`
@ -619,15 +616,15 @@ Absolute paths are used as-is.
## Precedence
Settings can come from multiple sources. Fabro resolves them in this order (first match wins):
For runs created by `fabro run` and `fabro create`, the CLI transmits sparse
flags and immutable workflow content, not machine or project run defaults.
Fabro resolves workflow behavior in this order (first match wins):
| Source | Priority |
|---|---|
| Node-level [stylesheet](/workflows/stylesheets) | Highest |
| CLI flags (`--model`, `--provider`, `--environment`) | |
| Run config TOML (`workflow.toml` or equivalent) | |
| Project defaults (`.fabro/project.toml`) | |
| Machine defaults (`~/.fabro/settings.toml`) | |
| Graphviz graph attributes (`default_model`, `default_provider`) | |
| Built-in defaults | Lowest |
@ -635,31 +632,32 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs
Stylesheet rules on individual nodes always take priority over run config values.
</Note>
### Project defaults (`.fabro/project.toml`)
### Project and machine settings
The `.fabro/project.toml` project config can set default values for any of the `[run.*]` sections described above. These defaults apply to all runs in the project unless the workflow config overrides them:
`fabro run` and `fabro create` do not transmit `[run]` or `[environments]`
from `.fabro/project.toml` or the CLI machine's `~/.fabro/settings.toml`.
When either key is present, the CLI warns with the affected file and key names,
but never includes the values in the warning or request. Move workflow-owned
behavior into each `workflow.toml`, and configure placement in server-managed
environments.
```toml title=".fabro/project.toml"
In particular, automatic pull-request behavior for CLI-created runs belongs in
the workflow:
```toml title="workflow.toml"
_version = 1
[run.model]
name = "claude-sonnet-4-5"
[workflow]
graph = "workflow.fabro"
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
dockerfile = { path = "Dockerfile" }
[run.pull_request]
enabled = true
draft = false
```
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, and `run.prepare.steps` replaces whole-list.
### Machine defaults
When running locally, the machine defaults at `~/.fabro/settings.toml` can set run-scoped defaults too. Same merge rules apply.
There is no compatibility field in the create request and no global
pull-request default supplied by the CLI. A server may still apply its own
active configuration independently; the warning does not claim otherwise.
## Validation
@ -669,6 +667,10 @@ Fabro validates the run config when it loads:
- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path.
- **Variable check** — Undefined workflow or prompt template variables produce diagnostics. `fabro validate` reports them as warnings; run-style commands treat them as errors before creating or starting a run.
The CLI also continues to parse and validate its active machine settings and a
discovered source-project config before creation. Malformed or unreadable files
remain hard local failures even though their run values are not transmitted.
Use `fabro preflight` to validate a run config without executing it:
```bash

View file

@ -70,7 +70,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro attach` | Attach to a running or finished workflow run |
| `fabro auth` | Manage CLI authentication state |
| `fabro completion` | Generate shell completions |
| `fabro create` | Create a workflow run (allocate run dir, persist spec) |
| `fabro create` | Register a local workflow version and create a submitted run |
| `fabro deny` | Deny pending workflow runs |
| `fabro discord` | Open the Discord community in the browser |
| `fabro docs` | Open the docs website in the browser |
@ -92,7 +92,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro resume` | Resume an interrupted workflow run |
| `fabro rewind` | Rewind a workflow run to an earlier checkpoint |
| `fabro rm` | Remove one or more workflow runs |
| `fabro run` | Launch a workflow run |
| `fabro run` | Register a local workflow version, create a run, and start it |
| `fabro sandbox` | Sandbox operations (cp, ssh, preview) |
| `fabro secret` | Manage server-owned secrets |
| `fabro server` | Server operations |
@ -332,7 +332,7 @@ fabro completion [OPTIONS] <SHELL>
### `fabro create`
Create a workflow run (allocate run dir, persist spec)
Register a local workflow version and create a submitted run
```bash
fabro create [OPTIONS] <WORKFLOW>
@ -342,7 +342,7 @@ fabro create [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Path to a .fabro workflow file or .toml task config |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
#### Options
@ -353,7 +353,7 @@ fabro create [OPTIONS] <WORKFLOW>
| `--dry-run` | Execute with simulated LLM backend |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read the workflow goal from a file |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
| `--label <key=value>` | Attach a label to this run (repeatable, format: KEY=VALUE) |
| `--model <model>` | Override default LLM model |
| `--parent <run>` | Link this run to an existing orchestration parent run |
@ -1061,7 +1061,7 @@ fabro rm [OPTIONS] <RUNS>...
### `fabro run`
Launch a workflow run
Register a local workflow version, create a run, and start it
```bash
fabro run [OPTIONS] <WORKFLOW>
@ -1071,7 +1071,7 @@ fabro run [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Path to a .fabro workflow file or .toml task config |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
#### Options
@ -1082,7 +1082,7 @@ fabro run [OPTIONS] <WORKFLOW>
| `--dry-run` | Execute with simulated LLM backend |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read the workflow goal from a file |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
| `--label <key=value>` | Attach a label to this run (repeatable, format: KEY=VALUE) |
| `--model <model>` | Override default LLM model |
| `--parent <run>` | Link this run to an existing orchestration parent run |

View file

@ -34,24 +34,27 @@ 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]` |
| Shared run defaults | `[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.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.
`fabro run` and `fabro create` do not copy the CLI machine's `[run]` or
`[environments]` tables into an intent request. They warn with only the file
path and affected key names when those tables are present. Put workflow-owned
behavior in `workflow.toml` and configure placement in environments managed by
the target server. The server may still use its own active `settings.toml`
independently.
See [Server Configuration](/administration/server-configuration) for the server-owned sections.
## Precedence
Shared layered domains (`[project]`, `[workflow]`, `[run]`) use this override order:
1. **CLI flags** — always win
2. **Environment overrides** — Fabro-defined override channels
3. **`workflow.toml`** — per-workflow overrides
4. **`.fabro/project.toml`** — project defaults
5. **`~/.fabro/settings.toml`** — machine defaults
6. **Built-in defaults**
For CLI-created workflow runs, sparse CLI flags override immutable
`workflow.toml` behavior. The CLI does not layer local project or machine run
defaults into the request. A target server resolves its own server-side policy
and environment catalog during admission.
Owner-specific domains (`[cli.*]`, `[server.*]`) use a narrower trust boundary — only CLI flags, env overrides, `~/.fabro/settings.toml`, and built-in defaults apply.
@ -116,39 +119,17 @@ output_cost_per_mtok = 8.00
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
[run.model]
name = "claude-sonnet-4-5"
[run.git.author]
name = "fabro-bot"
email = "fabro-bot@company.com"
[run.pull_request]
enabled = true
[run.integrations.github.permissions]
contents = "write"
pull_requests = "write"
[run.agent.mcps.filesystem]
type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
startup_timeout = "15s"
tool_timeout = "90s"
[run.agent.mcps.filesystem.env]
NODE_ENV = "production"
[run.agent.mcps.sentry]
type = "http"
url = "https://mcp.sentry.dev/mcp"
[run.agent.mcps.sentry.headers]
Authorization = "Bearer sk-xxx"
```
All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine.
<Note>
The `[run]` schemas below remain valid for a server's own active configuration,
but they are not CLI-side defaults for `fabro run` or `fabro create`. Put
automatic pull-request settings and other workflow-owned behavior in
`workflow.toml`; there is no compatibility request field or CLI global default.
</Note>
{/* generated:options */}
## `[cli.target]`

View file

@ -232,7 +232,7 @@ pub(crate) struct RunArgs {
#[command(flatten)]
pub(crate) inputs: InputOverrideArgs,
/// Path to a .fabro workflow file or .toml task config
/// Local workflow name, checkout path, .fabro file, or workflow TOML
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
@ -248,7 +248,7 @@ pub(crate) struct RunArgs {
#[arg(long)]
pub(crate) goal: Option<String>,
/// Read the workflow goal from a file
/// Read a per-run goal value from a local file
#[arg(long, conflicts_with = "goal")]
pub(crate) goal_file: Option<PathBuf>,
@ -1136,9 +1136,9 @@ pub(crate) struct UpgradeArgs {
#[derive(Subcommand)]
pub(crate) enum RunCommands {
/// Launch a workflow run
/// Register a local workflow version, create a run, and start it
Run(RunArgs),
/// Create a workflow run (allocate run dir, persist spec)
/// Register a local workflow version and create a submitted run
Create(RunArgs),
/// Start a created workflow run on the server
Start(StartArgs),

View file

@ -17,7 +17,7 @@ use crate::args::{
ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override,
};
use crate::server_client::Client;
use crate::user_config::LoadedSettings;
use crate::user_config::{LoadedSettings, RunSettingsKeyPresence};
use crate::{server_client, user_config};
#[derive(Clone, Debug)]
@ -33,24 +33,26 @@ pub(crate) enum ServerMode {
}
pub(crate) struct CommandContext {
printer: Printer,
printer: Printer,
process_local_json: bool,
cwd: PathBuf,
base_config_path: PathBuf,
cli_layer: CliLayer,
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
server_mode: ServerMode,
server: OnceCell<Arc<Client>>,
llm_source: OnceCell<Arc<dyn CredentialSource>>,
catalog: OnceLock<Arc<Catalog>>,
cwd: PathBuf,
base_config_path: PathBuf,
cli_layer: CliLayer,
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
run_settings_key_presence: RunSettingsKeyPresence,
server_mode: ServerMode,
server: OnceCell<Arc<Client>>,
llm_source: OnceCell<Arc<dyn CredentialSource>>,
catalog: OnceLock<Arc<Catalog>>,
}
struct ResolvedCommandSettings {
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
run_settings_key_presence: RunSettingsKeyPresence,
}
impl CommandContext {
@ -69,6 +71,7 @@ impl CommandContext {
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
run_settings_key_presence: resolved_settings.run_settings_key_presence,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),
@ -119,6 +122,10 @@ impl CommandContext {
&self.user_settings
}
pub(crate) fn run_settings_key_presence(&self) -> &RunSettingsKeyPresence {
&self.run_settings_key_presence
}
pub(crate) fn base_config_path(&self) -> &Path {
&self.base_config_path
}
@ -219,6 +226,7 @@ impl CommandContext {
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
run_settings_key_presence: resolved_settings.run_settings_key_presence,
server_mode,
server: OnceCell::new(),
llm_source: OnceCell::new(),
@ -249,9 +257,10 @@ fn load_merged_settings(
fn resolve_command_settings(loaded_settings: LoadedSettings) -> ResolvedCommandSettings {
ResolvedCommandSettings {
storage_dir: loaded_settings.storage_dir,
run_settings: loaded_settings.run_settings,
user_settings: loaded_settings.user_settings,
storage_dir: loaded_settings.storage_dir,
run_settings: loaded_settings.run_settings,
user_settings: loaded_settings.user_settings,
run_settings_key_presence: loaded_settings.run_settings_key_presence,
}
}
@ -293,6 +302,7 @@ mod tests {
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
run_settings_key_presence: resolved_settings.run_settings_key_presence,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),

View file

@ -61,12 +61,6 @@ pub(crate) async fn run_init(
# https://docs.fabro.computer/getting-started/quick-start
_version = 1
# Auto-create pull requests on successful workflow runs.
[run.pull_request]
enabled = true
draft = true
# auto_merge = true
",
)
.with_context(|| format!("failed to write {}", project_toml.display()))?;
@ -122,7 +116,18 @@ draft = true
let toml_path = workflow_dir.join("workflow.toml");
std::fs::write(
&toml_path,
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
"\
_version = 1
[workflow]
graph = \"workflow.fabro\"
# Auto-create pull requests on successful workflow runs.
[run.pull_request]
enabled = true
draft = true
# auto_merge = true
",
)
.with_context(|| format!("failed to write {}", toml_path.display()))?;
created.push(".fabro/workflows/hello/workflow.toml".to_string());

View file

@ -1,17 +1,17 @@
use anyhow::{Context as _, bail};
use fabro_config::RunLayer;
use fabro_config::user::active_settings_path;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use fabro_server::manifest_validation;
use fabro_types::RunId;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget};
use fabro_util::terminal::Styles;
use super::output::{api_diagnostics_to_local, print_workflow_summary};
use super::overrides::run_args_overrides;
use super::output::print_workflow_summary;
use super::overrides::prepare_intent_overrides;
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::commands::resolve_run_id;
use crate::manifest_args::run_manifest_args;
use crate::user_config::{RunSettingsKeyPresence, read_project_run_settings_key_presence};
pub(crate) struct CreatedRun {
pub(crate) run_id: RunId,
@ -31,53 +31,86 @@ pub(crate) async fn create_run(
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let cli_args_config = run_args_overrides(args)?;
let cwd = ctx.cwd().to_path_buf();
let mut built = build_run_manifest(ManifestBuildInput {
workflow: workflow_path.clone(),
cwd,
run_overrides: cli_args_config.run,
cli_overrides: cli_args_config.cli,
input_overrides: cli_args_config.input_overrides,
args: run_manifest_args(args),
environment_defaults: fabro_environment::seeded_catalog_layer(),
user_settings_path: Some(active_settings_path(None)),
let canonical_cwd = ctx.cwd().canonicalize().with_context(|| {
format!(
"failed to canonicalize caller working directory {}",
ctx.cwd().display()
)
})?;
let client = if let Some(parent_selector) = args.parent.as_deref() {
let client = ctx.server().await?;
let parent_id = resolve_run_id(client.as_ref(), parent_selector).await?;
built.manifest.parent_id = Some(parent_id.to_string());
Some(client)
} else {
None
};
let mut validation =
manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
manifest_validation::promote_template_undefined_variables_to_errors(&mut validation);
let diagnostics = api_diagnostics_to_local(&validation.workflow.diagnostics);
let user_workflows_root = fabro_util::Home::from_env().workflows_dir();
let package = fabro_manifest::resolve_local_workflow_package(
workflow_path,
&canonical_cwd,
Some(&user_workflows_root),
)
.map_err(anyhow::Error::new)?;
let prepared = prepare_intent_overrides(args, &canonical_cwd)?;
let validation = manifest_validation::validate_collected_workflow(
package.closure(),
Some(&prepared.run_layer),
&prepared.input_overrides,
)?;
if !quiet {
print_workflow_summary(
&validation.workflow,
Some(&built.target_path),
Some(&package.workflow_location().graph),
styles,
ctx.printer(),
);
}
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
{
if !validation.ok {
bail!("Validation failed");
}
let client = match client {
Some(client) => client,
None => ctx.server().await?,
warn_untransmitted_settings(
ctx,
styles,
ctx.base_config_path(),
*ctx.run_settings_key_presence(),
);
let project_config =
fabro_config::project::discover_project_config(&package.workflow_location().dir)?;
if let Some(path) = project_config.as_deref() {
warn_untransmitted_settings(
ctx,
styles,
path,
read_project_run_settings_key_presence(path)?,
);
}
let client = ctx.server().await?;
let parent_id = match args.parent.as_deref() {
Some(parent_selector) => Some(resolve_run_id(client.as_ref(), parent_selector).await?),
None => None,
};
let environment_id = args.environment.as_deref().unwrap_or("default");
let environment = client
.retrieve_environment(environment_id)
.await
.with_context(|| format!("could not retrieve environment `{environment_id}`"))?;
let target =
run_target_for_environment(environment.settings.provider, &canonical_cwd, ctx, styles)?;
let workflow_version_id = package.closure().root_id();
client
.register_workflow_versions(
package
.closure()
.versions()
.map(|(_, validated)| validated.version()),
)
.await
.context("could not register workflow versions")?;
let created_run_id = client
.create_run_from_manifest(built.manifest)
.create_run_from_intent(RunIntent {
workflow_version_id,
target,
args: prepared.intent_args,
environment_id: Some(environment.id.to_string()),
parent_id,
title: None,
goal: prepared.goal,
})
.await
.context("could not create run")?;
@ -85,3 +118,62 @@ pub(crate) async fn create_run(
run_id: created_run_id,
})
}
fn warn_untransmitted_settings(
ctx: &CommandContext,
styles: &Styles,
path: &Path,
presence: RunSettingsKeyPresence,
) {
let keys = presence.key_paths();
if keys.is_empty() {
return;
}
fabro_util::printerr!(
ctx.printer(),
"{} {} contains {}; `fabro run` and `fabro create` do not transmit these settings. Move workflow-owned run behavior, including `run.pull_request`, to `workflow.toml`; configure placement with server-managed environments.",
styles.yellow.apply_to("Warning:"),
path.display(),
keys.join(", "),
);
}
fn run_target_for_environment(
provider: EnvironmentProvider,
canonical_cwd: &Path,
ctx: &CommandContext,
styles: &Styles,
) -> anyhow::Result<RunTarget> {
match provider {
EnvironmentProvider::Local => {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"caller working directory is not valid UTF-8: {}",
canonical_cwd.display()
)
})?;
Ok(RunTarget::Folder {
path: path.to_string(),
})
}
EnvironmentProvider::Docker | EnvironmentProvider::Daytona => {
let Some(observation) = fabro_manifest::observe_git_run_target(canonical_cwd, None)
else {
return Ok(RunTarget::None {});
};
if observation.legacy_git_context.dirty == DirtyStatus::Dirty {
fabro_util::printerr!(
ctx.printer(),
"{} the caller Git working tree is dirty; uncommitted changes are not included in the run target.",
styles.yellow.apply_to("Warning:"),
);
}
let target = observation.run_target.ok_or_else(|| {
anyhow!(
"the caller Git checkout cannot be represented as a canonical GitHub run target"
)
})?;
Ok(RunTarget::Git(target))
}
}
}

View file

@ -1,11 +1,12 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use anyhow::{Context as _, Result, anyhow, bail};
use fabro_config::{
CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides, parse_labels,
};
use fabro_manifest::{RunOverrideInput, build_run_overrides};
use fabro_types::RunIntentArgs;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
@ -18,6 +19,14 @@ pub(crate) struct ManifestSettingsOverrides {
pub(crate) input_overrides: HashMap<String, toml::Value>,
}
#[derive(Debug)]
pub(super) struct PreparedIntentOverrides {
pub(super) run_layer: RunLayer,
pub(super) input_overrides: HashMap<String, toml::Value>,
pub(super) intent_args: RunIntentArgs,
pub(super) goal: Option<String>,
}
fn sparse_flag(value: bool) -> Option<bool> {
value.then_some(true)
}
@ -67,25 +76,74 @@ fn current_dir_or_dot() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverrides> {
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let mut run = build_run_overrides(RunOverrideInput {
goal: None,
model: args.model.as_deref(),
provider: args.provider.as_deref(),
environment: args.environment.as_deref(),
preserve_sandbox: sparse_flag(args.preserve_sandbox),
dry_run: sparse_flag(args.dry_run),
auto_approve: sparse_flag(args.auto_approve),
labels: parse_labels(&args.label),
pub(super) fn prepare_intent_overrides(
args: &RunArgs,
cwd: &Path,
) -> Result<PreparedIntentOverrides> {
let goal = match (args.goal.as_deref(), args.goal_file.as_deref()) {
(Some(_), Some(_)) => {
bail!("--goal and --goal-file are mutually exclusive; use exactly one")
}
(Some(goal), None) => Some(goal.to_string()),
(None, Some(path)) => {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
Some(
std::fs::read_to_string(&absolute)
.with_context(|| format!("failed to read goal file {}", absolute.display()))?,
)
}
(None, None) => None,
};
let input_overrides = parse_input_overrides(&args.inputs.values)?;
let inputs = input_overrides
.iter()
.map(|(key, value)| {
let value = match value {
toml::Value::String(value) => serde_json::Value::String(value.clone()),
toml::Value::Integer(value) => serde_json::Value::Number((*value).into()),
toml::Value::Float(value) => serde_json::Number::from_f64(*value)
.map(serde_json::Value::Number)
.ok_or_else(|| anyhow!("input override `{key}` must be a finite float"))?,
toml::Value::Boolean(value) => serde_json::Value::Bool(*value),
toml::Value::Datetime(_) | toml::Value::Array(_) | toml::Value::Table(_) => {
bail!("input override `{key}` must be a scalar value")
}
};
Ok((key.clone(), value))
})
.collect::<Result<HashMap<_, _>>>()?;
let labels = parse_labels(&args.label);
let dry_run = sparse_flag(args.dry_run);
let auto_approve = sparse_flag(args.auto_approve);
let preserve_sandbox = sparse_flag(args.preserve_sandbox);
let run_layer = build_run_overrides(RunOverrideInput {
goal: goal.as_deref(),
model: args.model.as_deref(),
provider: args.provider.as_deref(),
environment: args.environment.as_deref(),
preserve_sandbox,
dry_run,
auto_approve,
labels: labels.clone(),
});
run.goal = goal;
Ok(ManifestSettingsOverrides {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
input_overrides: parse_input_overrides(&args.inputs.values)?,
Ok(PreparedIntentOverrides {
run_layer,
input_overrides,
intent_args: RunIntentArgs {
model: args.model.clone(),
provider: args.provider.clone(),
inputs,
labels,
dry_run,
auto_approve,
preserve_sandbox,
},
goal,
})
}
@ -119,6 +177,150 @@ pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestS
mod tests {
use super::*;
fn run_args() -> RunArgs {
RunArgs {
target: crate::args::ServerTargetArgs::default(),
inputs: crate::args::InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
dry_run: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
environment: None,
label: Vec::new(),
parent: None,
preserve_sandbox: false,
detach: false,
}
}
#[test]
fn intent_overrides_preserve_typed_values_and_sparse_flags() {
let mut args = run_args();
args.inputs.values = vec![
"string=hello".to_string(),
"boolean=true".to_string(),
"integer=42".to_string(),
"float=1.25".to_string(),
];
args.goal = Some("Ship it".to_string());
args.model = Some("gpt-5".to_string());
args.provider = Some("openai".to_string());
args.environment = Some("cloud".to_string());
args.label = vec!["team=cli".to_string()];
args.dry_run = true;
args.auto_approve = true;
args.preserve_sandbox = true;
args.verbose = true;
let prepared = prepare_intent_overrides(&args, Path::new("/caller")).unwrap();
assert_eq!(prepared.goal.as_deref(), Some("Ship it"));
assert_eq!(
prepared.intent_args.inputs,
HashMap::from([
("string".to_string(), serde_json::json!("hello")),
("boolean".to_string(), serde_json::json!(true)),
("integer".to_string(), serde_json::json!(42)),
("float".to_string(), serde_json::json!(1.25)),
])
);
assert_eq!(prepared.intent_args.model.as_deref(), Some("gpt-5"));
assert_eq!(prepared.intent_args.provider.as_deref(), Some("openai"));
assert_eq!(
prepared.intent_args.labels.get("team"),
Some(&"cli".to_string())
);
assert_eq!(prepared.intent_args.dry_run, Some(true));
assert_eq!(prepared.intent_args.auto_approve, Some(true));
assert_eq!(prepared.intent_args.preserve_sandbox, Some(true));
assert!(
!serde_json::to_value(&prepared.intent_args)
.unwrap()
.as_object()
.unwrap()
.contains_key("verbose")
);
assert_eq!(
prepared.input_overrides,
HashMap::from([
(
"string".to_string(),
toml::Value::String("hello".to_string())
),
("boolean".to_string(), toml::Value::Boolean(true)),
("integer".to_string(), toml::Value::Integer(42)),
("float".to_string(), toml::Value::Float(1.25)),
])
);
let RunGoalLayer::Inline(goal) = prepared.run_layer.goal.unwrap() else {
panic!("prepared run layer should use an inline goal");
};
assert_eq!(goal.as_source(), "Ship it");
}
#[test]
fn intent_overrides_leave_false_flags_absent() {
let prepared = prepare_intent_overrides(&run_args(), Path::new("/caller")).unwrap();
assert_eq!(prepared.intent_args.dry_run, None);
assert_eq!(prepared.intent_args.auto_approve, None);
assert_eq!(prepared.intent_args.preserve_sandbox, None);
}
#[test]
fn intent_goal_files_are_read_by_value_from_relative_and_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let relative = PathBuf::from("goals/task.md");
std::fs::create_dir_all(dir.path().join("goals")).unwrap();
std::fs::write(dir.path().join(&relative), "Goal from file").unwrap();
for goal_file in [relative, dir.path().join("goals/task.md")] {
let mut args = run_args();
args.goal_file = Some(goal_file);
let prepared = prepare_intent_overrides(&args, dir.path()).unwrap();
assert_eq!(prepared.goal.as_deref(), Some("Goal from file"));
let RunGoalLayer::Inline(goal) = prepared.run_layer.goal.unwrap() else {
panic!("goal-file content should become an inline validation override");
};
assert_eq!(goal.as_source(), "Goal from file");
}
}
#[test]
fn intent_goal_file_read_errors_preserve_the_resolved_path_and_source() {
let mut args = run_args();
args.goal_file = Some(PathBuf::from("missing.md"));
let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err();
assert!(error.to_string().contains("/caller/missing.md"));
assert!(error.source().is_some());
}
#[test]
fn intent_goal_and_goal_file_together_are_rejected_defensively() {
let mut args = run_args();
args.goal = Some("inline".to_string());
args.goal_file = Some(PathBuf::from("goal.md"));
let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err();
assert!(error.to_string().contains("mutually exclusive"));
}
#[test]
fn intent_overrides_reject_non_finite_float_with_input_key() {
let mut args = run_args();
args.inputs.values = vec!["temperature=nan".to_string()];
let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err();
assert!(error.to_string().contains("temperature"));
assert!(error.to_string().contains("finite"));
}
#[test]
fn goal_and_goal_file_together_is_rejected() {
let err = goal_layer_from_args(

View file

@ -46,7 +46,7 @@ pub(crate) fn print() {
&[
("validate", "Validate a workflow"),
("preflight", "Validate run configuration without executing"),
("run", "Launch a workflow run"),
("run", "Register and run a local workflow"),
],
cmd_width,
);

View file

@ -1,7 +1,10 @@
use fabro_api::types;
use crate::args::{PreflightArgs, RunArgs};
use crate::args::PreflightArgs;
#[cfg(test)]
use crate::args::RunArgs;
#[cfg(test)]
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
let payload = types::ManifestArgs {
auto_approve: args.auto_approve.then_some(true),

View file

@ -3,10 +3,12 @@ use std::str::FromStr;
use anyhow::{Context, Result, anyhow};
pub(crate) use fabro_client::ServerTarget;
use fabro_config::parse::{SettingsSource, validate_settings_source};
pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir};
use fabro_config::user::{default_settings_path, default_socket_path};
use fabro_config::{
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, SettingsLayer,
UserSettingsBuilder,
};
use fabro_static::EnvVars;
use fabro_types::settings::RunNamespace;
@ -20,13 +22,36 @@ use tracing::debug;
use crate::args::ServerTargetArgs;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct RunSettingsKeyPresence {
pub(crate) run: bool,
pub(crate) environments: bool,
}
impl RunSettingsKeyPresence {
fn from_document(document: &toml::Value) -> Self {
Self {
run: document.get("run").is_some(),
environments: document.get("environments").is_some(),
}
}
pub(crate) fn key_paths(self) -> Vec<&'static str> {
[(self.run, "run"), (self.environments, "environments")]
.into_iter()
.filter_map(|(present, key)| present.then_some(key))
.collect()
}
}
pub(crate) struct LoadedSettings {
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<LogFilter>,
pub(crate) config_log_destination: Option<LogDestination>,
pub(crate) run_settings: std::result::Result<RunNamespace, SharedError>,
pub(crate) server_settings: std::result::Result<ServerSettings, SharedError>,
pub(crate) user_settings: UserSettings,
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<LogFilter>,
pub(crate) config_log_destination: Option<LogDestination>,
pub(crate) run_settings: std::result::Result<RunNamespace, SharedError>,
pub(crate) server_settings: std::result::Result<ServerSettings, SharedError>,
pub(crate) user_settings: UserSettings,
pub(crate) run_settings_key_presence: RunSettingsKeyPresence,
}
pub(crate) fn load_resolved_settings(
@ -35,6 +60,7 @@ pub(crate) fn load_resolved_settings(
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
let document = load_settings_document(config_path)?;
let run_settings_key_presence = RunSettingsKeyPresence::from_document(&document);
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir);
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
@ -54,9 +80,35 @@ pub(crate) fn load_resolved_settings(
run_settings,
server_settings,
user_settings,
run_settings_key_presence,
})
}
#[expect(
clippy::disallowed_methods,
reason = "sync project settings inspection during CLI command preparation"
)]
pub(crate) fn read_project_run_settings_key_presence(
path: &Path,
) -> anyhow::Result<RunSettingsKeyPresence> {
let source = std::fs::read_to_string(path)
.map_err(|source| fabro_config::Error::read_file(path, source))?;
let document: toml::Value = toml::from_str(&source).map_err(|source| {
fabro_config::Error::parse_file(
"Failed to parse settings file",
path,
ParseError::Toml(source.to_string()),
)
})?;
let layer = source.parse::<SettingsLayer>().map_err(|source| {
fabro_config::Error::parse_file("Failed to parse settings file", path, source)
})?;
validate_settings_source(&layer, SettingsSource::Project).map_err(|source| {
fabro_config::Error::parse_file("Failed to parse settings file", path, source)
})?;
Ok(RunSettingsKeyPresence::from_document(&document))
}
fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result<toml::Value> {
load_settings_document_with_lookup(config_path, process_env_var_os)
}
@ -333,6 +385,7 @@ pub(crate) fn load_resolved_settings_from_toml(
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
let run_settings_key_presence = RunSettingsKeyPresence::from_document(&document);
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir);
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
@ -359,6 +412,7 @@ pub(crate) fn load_resolved_settings_from_toml(
run_settings,
server_settings,
user_settings,
run_settings_key_presence,
})
}
@ -383,6 +437,72 @@ mod tests {
UserSettingsBuilder::from_toml(source).expect("fixture should resolve")
}
#[test]
fn run_settings_key_presence_distinguishes_absent_empty_and_populated_keys() {
let absent: toml::Value = toml::from_str("_version = 1\n").unwrap();
let empty_run: toml::Value = toml::from_str("_version = 1\n\n[run]\n").unwrap();
let empty_environments: toml::Value =
toml::from_str("_version = 1\n\n[environments]\n").unwrap();
let populated: toml::Value = toml::from_str(
"_version = 1\n\n[run.model]\nname = \"gpt-5\"\n\n[environments.cloud]\nprovider = \"docker\"\n",
)
.unwrap();
assert_eq!(
RunSettingsKeyPresence::from_document(&absent),
RunSettingsKeyPresence::default()
);
assert_eq!(
RunSettingsKeyPresence::from_document(&empty_run),
RunSettingsKeyPresence {
run: true,
environments: false,
}
);
assert_eq!(
RunSettingsKeyPresence::from_document(&empty_environments),
RunSettingsKeyPresence {
run: false,
environments: true,
}
);
assert_eq!(
RunSettingsKeyPresence::from_document(&populated),
RunSettingsKeyPresence {
run: true,
environments: true,
}
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary project settings fixture with sync std::fs"
)]
fn project_run_settings_key_presence_validates_the_same_source() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("project.toml");
std::fs::write(&path, "_version = 1\n\n[run]\n\n[environments]\n").unwrap();
assert_eq!(
read_project_run_settings_key_presence(&path).unwrap(),
RunSettingsKeyPresence {
run: true,
environments: true,
}
);
std::fs::write(
&path,
"_version = 1\n\n[environments.cloud]\ncwd = \"/tmp\"\n",
)
.unwrap();
let error = read_project_run_settings_key_presence(&path).unwrap_err();
assert!(error.to_string().contains(&path.display().to_string()));
assert!(error.source().is_some());
}
#[test]
fn exec_has_no_server_target_by_default() {
assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None);

View file

@ -303,7 +303,7 @@ script = "workflow-setup"
// ---------------------------------------------------------------------------
#[test]
fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
fn create_explicit_workflow_warns_without_transmitting_machine_or_project_run_settings() {
let mut context = test_context!();
let (project, storage_dir) = setup_external_workflow_fixture(&mut context);
context.ensure_home_server_auth_methods();
@ -320,10 +320,20 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
"--dry-run",
"--model",
"gpt-5.4-pro",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()
.success();
let stderr = String::from_utf8_lossy(&create.get_output().stderr);
assert!(stderr.contains("settings.toml contains run"), "{stderr}");
assert!(stderr.contains("project.toml contains run"), "{stderr}");
assert!(
stderr.contains("do not transmit these settings"),
"{stderr}"
);
assert!(stderr.contains("workflow.toml"), "{stderr}");
let run_id = created_run_id(create.get_output());
let runs_dir = storage_dir.join("scratch");
@ -348,6 +358,8 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
let run_spec = serde_json::to_value(&state.spec).unwrap();
assert_eq!(
run_spec["settings"]["run"]["execution"]["approval"].as_str(),
// The CLI did not transmit its machine setting. The in-process server
// may still apply its own active configuration independently.
Some("auto")
);
assert_eq!(

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,8 @@ fn help() {
Usage: fabro [OPTIONS] [COMMAND]
Commands:
run Launch a workflow run
create Create a workflow run (allocate run dir, persist spec)
run Register a local workflow version, create a run, and start it
create Register a local workflow version and create a submitted run
start Start a created workflow run on the server
attach Attach to a running or finished workflow run
events View the event log of a workflow run
@ -96,7 +96,7 @@ fn no_args_prints_curated_landing() {
fabro validate Validate a workflow
fabro preflight Validate run configuration without executing
fabro run Launch a workflow run
fabro run Register and run a local workflow
Inspect runs

View file

@ -63,12 +63,6 @@ fn repo_init_creates_project_toml_and_hello_workflow() {
# https://docs.fabro.computer/getting-started/quick-start
_version = 1
# Auto-create pull requests on successful workflow runs.
[run.pull_request]
enabled = true
draft = true
# auto_merge = true
"###
);
assert_snapshot!(
@ -96,6 +90,12 @@ fn repo_init_creates_project_toml_and_hello_workflow() {
[workflow]
graph = "workflow.fabro"
# Auto-create pull requests on successful workflow runs.
[run.pull_request]
enabled = true
draft = true
# auto_merge = true
"###
);
}

View file

@ -6,7 +6,7 @@
use fabro_config::Storage;
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use fabro_vault::{SecretType, Vault};
use httpmock::MockServer;
use httpmock::{HttpMockResponse, Mock, MockServer};
use serde_json::Value;
use super::support::{
@ -31,6 +31,47 @@ fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
)
}
fn mock_environment<'a>(server: &'a MockServer, id: &str, provider: &str) -> Mock<'a> {
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/environments/{id}"));
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"id": id,
"revision": "0".repeat(64),
"provider": provider,
"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": {}
}));
})
}
fn mock_workflow_version_registrations(server: &MockServer) -> Mock<'_> {
server.mock(|when, then| {
when.method("POST").path("/api/v1/workflow-versions");
then.respond_with(|request| {
let version: fabro_types::WorkflowVersion =
serde_json::from_slice(request.body_ref()).unwrap();
HttpMockResponse::builder()
.status(201)
.header("content-type", "application/json")
.body(
serde_json::json!({ "workflow_version_id": version.id().unwrap() }).to_string(),
)
.build()
});
})
}
fn remote_run_state_response(run_id: &str) -> serde_json::Value {
let mut state = run_projection_json(
run_id,
@ -118,12 +159,12 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Launch a workflow run
Register a local workflow version, create a run, and start it
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Path to a .fabro workflow file or .toml task config
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
Options:
--json Output as JSON [env: FABRO_JSON=]
@ -135,7 +176,7 @@ fn help() {
--auto-approve Auto-approve all human gates
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
@ -154,6 +195,8 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
@ -188,6 +231,8 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
environment_mock.assert();
version_mock.assert();
create_mock.assert();
start_mock.assert();
assert_eq!(output_stderr(&output), "");
@ -198,12 +243,14 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
}
#[test]
fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() {
fn run_parent_resolves_parent_and_sends_parent_id_in_intent() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let parent_id = unique_run_id();
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST")
.path("/api/v1/runs")
@ -243,6 +290,8 @@ fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() {
String::from_utf8_lossy(&output.stderr)
);
resolve_mock.assert();
environment_mock.assert();
version_mock.assert();
create_mock.assert();
start_mock.assert();
assert_eq!(
@ -256,6 +305,8 @@ fn detach_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
@ -289,6 +340,8 @@ fn detach_uses_configured_server_target_without_server_flag() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
environment_mock.assert();
version_mock.assert();
create_mock.assert();
start_mock.assert();
assert_eq!(
@ -301,6 +354,8 @@ fn detach_uses_configured_server_target_without_server_flag() {
fn run_create_failure_shows_action_context_and_response_body() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(422)
@ -328,6 +383,8 @@ fn run_create_failure_shows_action_context_and_response_body() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
environment_mock.assert();
version_mock.assert();
create_mock.assert();
let stderr = output_stderr(&output);
@ -479,6 +536,8 @@ fn detach_cli_server_target_overrides_configured_server_target() {
});
let cli_server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&cli_server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&cli_server);
let cli_create = cli_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
@ -514,6 +573,8 @@ fn detach_cli_server_target_overrides_configured_server_target() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
environment_mock.assert();
version_mock.assert();
cli_create.assert();
cli_start.assert();
config_create.assert_calls(0);
@ -529,6 +590,8 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let preflight = server.mock(|when, then| {
when.method("POST").path("/api/v1/preflight");
then.status(500)
@ -626,6 +689,8 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
String::from_utf8_lossy(&output.stderr)
);
preflight.assert_calls(0);
environment_mock.assert();
version_mock.assert();
first_page.assert();
second_page.assert();
@ -1101,6 +1166,8 @@ fn detach_creates_run_dir_with_detach_log() {
"--detach",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()

View file

@ -46,6 +46,8 @@ fn start_by_run_id_starts_created_run() {
"create",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()
@ -96,6 +98,8 @@ fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
"create",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()

View file

@ -124,6 +124,8 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
"create",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()
@ -246,6 +248,8 @@ digraph BarBaz {
"create",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow_path.to_str().unwrap(),
])
.assert()
@ -314,6 +318,8 @@ digraph FooWorkflow {
"create",
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow_path.to_str().unwrap(),
])
.assert()

View file

@ -704,6 +704,7 @@ pub(crate) async fn create_run_from_intent(
});
let entrypoint = lowered.entrypoint.clone();
let workflow_slug = fabro_config::project::workflow_slug_from_path(entrypoint.as_path());
let raw_compiler_input = RawRunCompilerInput {
workflow_bundle: lowered.workflow_bundle,
entrypoint: lowered.entrypoint,
@ -729,7 +730,7 @@ pub(crate) async fn create_run_from_intent(
// admission via `with_target_and_git`; the compiler never reads them.
git: None,
storage_root: state.server_storage_dir(),
workflow_slug: None,
workflow_slug,
workflow_version_id: Some(intent.workflow_version_id),
target: None,
provenance: run_provenance(&headers, &actor),

View file

@ -3564,10 +3564,17 @@ async fn store_workflow_version(
graph: &str,
workflow_toml: Option<&str>,
) -> fabro_types::WorkflowVersionId {
let mut files = std::collections::BTreeMap::from([(
fabro_types::WorkflowPath::new("workflow.fabro").unwrap(),
graph.to_string(),
)]);
store_workflow_version_with_entrypoint(state, "workflow.fabro", graph, workflow_toml).await
}
async fn store_workflow_version_with_entrypoint(
state: &AppState,
entrypoint: &str,
graph: &str,
workflow_toml: Option<&str>,
) -> fabro_types::WorkflowVersionId {
let entrypoint = fabro_types::WorkflowPath::new(entrypoint).unwrap();
let mut files = std::collections::BTreeMap::from([(entrypoint.clone(), graph.to_string())]);
if let Some(workflow_toml) = workflow_toml {
files.insert(
fabro_types::WorkflowPath::new("workflow.toml").unwrap(),
@ -3582,12 +3589,9 @@ async fn store_workflow_version(
"FROM alpine:3".to_string(),
);
}
let version = fabro_types::WorkflowVersion::new(
fabro_types::WorkflowPath::new("workflow.fabro").unwrap(),
files,
std::collections::BTreeMap::new(),
)
.unwrap();
let version =
fabro_types::WorkflowVersion::new(entrypoint, files, std::collections::BTreeMap::new())
.unwrap();
let version = fabro_workflow_version::ValidatedWorkflowVersion::new(version).unwrap();
let blobs = state.store_ref().blobs();
fabro_workflow_version::WorkflowVersionStore::new(blobs)
@ -3596,6 +3600,48 @@ async fn store_workflow_version(
.unwrap()
}
#[tokio::test]
async fn post_runs_run_intent_derives_workflow_slug_from_immutable_entrypoint() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
for (entrypoint, expected_slug) in [
("deploy/workflow.fabro", "deploy"),
("workflow.fabro", "workflow"),
] {
let workflow_version_id =
store_workflow_version_with_entrypoint(&state, entrypoint, MINIMAL_DOT, None).await;
let body = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
}),
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
assert_eq!(
projection.spec.workflow_slug.as_deref(),
Some(expected_slug)
);
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
);
}
}
#[tokio::test]
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
let state = test_app_state();

View file

@ -701,6 +701,17 @@ impl Client {
self.submit_create_run(manifest.into()).await
}
/// Retrieves one canonical server-managed environment by ID.
pub async fn retrieve_environment(&self, id: &str) -> Result<types::Environment> {
let response = self
.send_api(|client| {
let id = id.to_string();
async move { client.retrieve_environment().id(id).send().await }
})
.await?;
Ok(response.into_inner())
}
/// Registers one workflow version and verifies the server assigned the
/// content-derived id, so a mismatched response fails loudly here rather
/// than being trusted downstream.
@ -2410,6 +2421,75 @@ mod tests {
.build()
}
fn environment_json(id: &str, provider: &str) -> serde_json::Value {
json!({
"id": id,
"revision": "0".repeat(64),
"provider": provider,
"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": {}
})
}
#[tokio::test]
async fn retrieve_environment_returns_the_canonical_environment() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(GET).path("/api/v1/environments/local");
then.status(200)
.header("content-type", "application/json")
.json_body(environment_json("local", "local"));
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let environment = client.retrieve_environment("local").await.unwrap();
mock.assert_async().await;
assert_eq!(environment.id.as_str(), "local");
assert_eq!(
environment.settings.provider,
fabro_types::settings::run::EnvironmentProvider::Local
);
}
#[tokio::test]
async fn retrieve_environment_preserves_api_failure_metadata() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(GET).path("/api/v1/environments/missing");
then.status(404)
.header("content-type", "application/json")
.json_body(json!({
"errors": [{
"status": "404",
"title": "Not Found",
"detail": "environment not found",
"code": "environment_not_found"
}]
}));
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let error = client.retrieve_environment("missing").await.unwrap_err();
mock.assert_async().await;
let failure = api_failure_for(&error).expect("API failure metadata should be preserved");
assert_eq!(failure.status, fabro_http::StatusCode::NOT_FOUND);
assert_eq!(failure.code.as_deref(), Some("environment_not_found"));
}
#[tokio::test]
async fn create_workflow_version_posts_exact_version_and_returns_server_id() {
let server = MockServer::start_async().await;