mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Create CLI runs from immutable workflow intents
This commit is contained in:
parent
d260ae89b5
commit
2507a0075f
22 changed files with 1939 additions and 243 deletions
31
docs/public/changelog/2026-08-31.mdx
Normal file
31
docs/public/changelog/2026-08-31.mdx
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]`
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"###
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue