mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # lib/crates/fabro-cli/src/commands/install.rs
This commit is contained in:
commit
3085ad56f8
227 changed files with 13047 additions and 8066 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -1487,6 +1487,7 @@ dependencies = [
|
|||
"fabro-model",
|
||||
"fabro-sandbox",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
"glob",
|
||||
|
|
@ -1928,6 +1929,7 @@ dependencies = [
|
|||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"toml 0.8.23",
|
||||
"toml_edit",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
|
|
@ -2059,6 +2061,8 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml 0.8.23",
|
||||
"ulid",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ mime_guess = "2"
|
|||
indicatif = "0.18"
|
||||
termimad = "0.34"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import type { PaginationMeta, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { PaginationMeta } from "@qltysh/fabro-api-client";
|
||||
|
||||
/**
|
||||
* Opaque settings payload returned by `/api/v1/runs/:id/settings`. Mirrors the
|
||||
* v2 `SettingsFile` shape in `lib/crates/fabro-types/src/settings/tree.rs`,
|
||||
* with secret-bearing subtrees dropped before serialization. Treated as a
|
||||
* loose JSON object on the web side — consumers only render it.
|
||||
*/
|
||||
export type RunSettings = Record<string, unknown>;
|
||||
|
||||
export interface WorkflowScheduleSummary {
|
||||
expression: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
|||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
import type { RunSettings } from "../lib/workflow-api";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { apiJson } from "../api";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import type { ServerSettings } from "@qltysh/fabro-api-client";
|
||||
|
||||
/**
|
||||
* Opaque server settings payload returned by `/api/v1/settings`. Mirrors the
|
||||
* v2 `SettingsFile` shape with secret-bearing subtrees dropped before
|
||||
* serialization. The UI only renders it as JSON.
|
||||
*/
|
||||
type ServerSettings = Record<string, unknown>;
|
||||
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Settings — Fabro" }];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||
import { apiJson } from "../api";
|
||||
import type { RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api";
|
||||
import type { RunSettings, WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api";
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string;
|
||||
|
|
@ -13,8 +12,11 @@ export interface WorkflowEntry {
|
|||
graph: string;
|
||||
}
|
||||
|
||||
// Keep this exported for backward compatibility with other routes that import it.
|
||||
// It will be populated by the loader, but the static version is kept as fallback.
|
||||
// Static sample data used by the `workflow-definition` index route for the
|
||||
// hardcoded showcase workflows. Shape mirrors the v2 `SettingsFile` JSON
|
||||
// returned by `/api/v1/runs/:id/settings` (see the Rust
|
||||
// `fabro_types::settings::SettingsFile` type). Fields are opaque to the
|
||||
// `RunSettings` TypeScript type, which is a bare `Record<string, unknown>`.
|
||||
export const workflowData: Record<string, WorkflowEntry> = {
|
||||
fix_build: {
|
||||
name: "Fix Build",
|
||||
|
|
@ -22,17 +24,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "fix_build.fabro",
|
||||
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Diagnose and fix CI build failures",
|
||||
graph: "fix_build.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { repo_url: "https://github.com/org/service", branch: "main" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 60,
|
||||
labels: { project: "fix-build" },
|
||||
snapshot: { name: "fix-build-dev", cpu: 4, memory: 8, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Diagnose and fix CI build failures",
|
||||
inputs: { repo_url: "https://github.com/org/service", branch: "main" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 60,
|
||||
labels: { project: "fix-build" },
|
||||
snapshot: { name: "fix-build-dev", cpu: 4, memory: "8GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -63,18 +66,25 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "implement.fabro",
|
||||
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Implement feature from technical blueprint",
|
||||
graph: "implement.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { spec_path: "specs/feature.md", test_framework: "vitest" },
|
||||
setup: { commands: ["bun install", "bun run typecheck"], timeout_ms: 120000 },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "implement", team: "engineering" },
|
||||
snapshot: { name: "implement-dev", cpu: 4, memory: 8, disk: 20 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Implement feature from technical blueprint",
|
||||
inputs: { spec_path: "specs/feature.md", test_framework: "vitest" },
|
||||
model: { name: "claude-sonnet" },
|
||||
prepare: {
|
||||
steps: [
|
||||
{ command: ["bun", "install"] },
|
||||
{ command: ["bun", "run", "typecheck"] },
|
||||
],
|
||||
timeout: "120s",
|
||||
},
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "implement", team: "engineering" },
|
||||
snapshot: { name: "implement-dev", cpu: 4, memory: "8GB", disk: "20GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -119,17 +129,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "sync_drift.fabro",
|
||||
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Detect and reconcile configuration drift across environments",
|
||||
graph: "sync_drift.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "sync-drift", team: "platform" },
|
||||
snapshot: { name: "sync-drift-dev", cpu: 2, memory: 4, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Detect and reconcile configuration drift across environments",
|
||||
inputs: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "sync-drift", team: "platform" },
|
||||
snapshot: { name: "sync-drift-dev", cpu: 2, memory: "4GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -164,17 +175,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "expand.fabro",
|
||||
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Propose and implement incremental product improvements",
|
||||
graph: "expand.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { analytics_window: "30d", min_confidence: "0.8" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 180,
|
||||
labels: { project: "expand", team: "product" },
|
||||
snapshot: { name: "expand-dev", cpu: 2, memory: 4, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Propose and implement incremental product improvements",
|
||||
inputs: { analytics_window: "30d", min_confidence: "0.8" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 180,
|
||||
labels: { project: "expand", team: "product" },
|
||||
snapshot: { name: "expand-dev", cpu: 2, memory: "4GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ description: "Server-owned settings.toml sections, CLI overrides, and environmen
|
|||
|
||||
`fabro server start` reads `~/.fabro/settings.toml` by default. This is the same file schema used by the CLI.
|
||||
|
||||
On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[server].target`.
|
||||
On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`.
|
||||
|
||||
<Note>
|
||||
Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`.
|
||||
|
|
@ -17,84 +17,88 @@ Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Re
|
|||
|
||||
| Scope | Examples |
|
||||
|---|---|
|
||||
| Server-owned | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` |
|
||||
| Shared with CLI and workflow defaults | `[llm]`, `[log]`, `[git]`, `[setup]`, `[sandbox]`, `[checkpoint]`, `[vars]`, `[pull_request]` |
|
||||
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]`, `[features]` |
|
||||
| Shared run defaults (layered through `fabro.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
|
||||
|
||||
The CLI-only `[server]` section still belongs in the client machine's `settings.toml`. It tells CLI commands where to find a server. The server process does not read `[server].target` for its own binding or routing.
|
||||
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
|
||||
|
||||
### Full reference
|
||||
|
||||
```toml title="settings.toml"
|
||||
# Maximum concurrent workflow runs (default: 5)
|
||||
max_concurrent_runs = 8
|
||||
_version = 1
|
||||
|
||||
# Override the default data directory (default: ~/.fabro)
|
||||
data_dir = "/var/lib/fabro"
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "0.0.0.0:3000"
|
||||
|
||||
[api]
|
||||
base_url = "https://fabro.example.com/api/v1"
|
||||
|
||||
[api.tls]
|
||||
[server.listen.tls]
|
||||
cert = "/etc/fabro/tls/cert.pem"
|
||||
key = "/etc/fabro/tls/key.pem"
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
|
||||
# Authentication strategies (array of Jwt or Mtls)
|
||||
[[api.authentication_strategies]]
|
||||
type = "Jwt"
|
||||
[server.api]
|
||||
url = "https://fabro.example.com/api/v1"
|
||||
|
||||
[web]
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "https://fabro-web.example.com"
|
||||
|
||||
[web.auth]
|
||||
provider = "Github"
|
||||
[server.auth.web]
|
||||
allowed_usernames = ["alice", "bob"]
|
||||
|
||||
[git]
|
||||
provider = "Github"
|
||||
[server.auth.web.providers.github]
|
||||
enabled = true
|
||||
client_id = "Iv1.abc123"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "123456"
|
||||
client_id = "Iv1.abc123"
|
||||
|
||||
[log]
|
||||
[server.integrations.github.webhooks]
|
||||
strategy = "tailscale_funnel"
|
||||
|
||||
[server.storage]
|
||||
root = "/var/lib/fabro"
|
||||
|
||||
[server.scheduler]
|
||||
max_concurrent_runs = 8
|
||||
|
||||
[server.logging]
|
||||
level = "info"
|
||||
|
||||
[git.author]
|
||||
# Run defaults — applied to every run unless overridden by workflow/project config
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
provider = "anthropic"
|
||||
fallbacks = ["gemini", "openai"]
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "npm install"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[run.sandbox.daytona.labels]
|
||||
team = "platform"
|
||||
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
||||
|
||||
[run.inputs]
|
||||
default_branch = "main"
|
||||
|
||||
[run.git.author]
|
||||
name = "fabro-bot"
|
||||
email = "fabro-bot@company.com"
|
||||
|
||||
[git.webhooks]
|
||||
strategy = "tailscale_funnel"
|
||||
|
||||
# Run defaults — applied to every run unless overridden by workflow/project config
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
provider = "anthropic"
|
||||
|
||||
[llm.fallbacks]
|
||||
anthropic = ["gemini", "openai"]
|
||||
|
||||
[setup]
|
||||
commands = ["npm install"]
|
||||
timeout_ms = 120000
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
team = "platform"
|
||||
|
||||
[features]
|
||||
retros = true
|
||||
|
||||
[checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
||||
|
||||
[vars]
|
||||
default_branch = "main"
|
||||
session_sandboxes = true
|
||||
```
|
||||
|
||||
### CLI overrides
|
||||
|
|
@ -116,36 +120,38 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag
|
|||
|
||||
CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order.
|
||||
|
||||
### `[web]` section
|
||||
### `[server.web]` section
|
||||
|
||||
Control the embedded SPA and browser-oriented routes.
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` |
|
||||
| `url` | External web UI URL used for OAuth redirects | `http://localhost:3000` |
|
||||
| `url` | External web UI URL used for OAuth redirects | none (no implicit derivation from `server.listen`) |
|
||||
|
||||
When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`.
|
||||
|
||||
### Run defaults
|
||||
|
||||
The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `settings.toml` act as defaults for every run.
|
||||
The `[run.*]` sections in `settings.toml` act as defaults for every run.
|
||||
|
||||
On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` / `run.toml` and `fabro.toml`.
|
||||
On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` and `fabro.toml`.
|
||||
|
||||
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `storage_dir`, `[api]`, `[web]`, `[features]`, and `max_concurrent_runs` always come from the server machine's own `settings.toml` or `fabro server start` flags.
|
||||
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[features]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags.
|
||||
|
||||
For `[vars]`, Daytona labels, and checkpoint exclude globs, values are **merged** and the more specific layer wins on key collisions. All other fields use "first non-empty wins" precedence.
|
||||
Merge rules follow the normative matrix: `[run.inputs]` replaces wholesale, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
|
||||
|
||||
### `[log]` section
|
||||
### `[server.logging]` section
|
||||
|
||||
Configure the default log level without environment variables. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`.
|
||||
Configure the default server log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[server.logging].level` > `"info"`.
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `level` | Log level: `error`, `warn`, `info`, `debug`, `trace` | `"info"` |
|
||||
|
||||
### `[git.author]` section
|
||||
The CLI has its own `[cli.logging]` section.
|
||||
|
||||
### `[run.git.author]` section
|
||||
|
||||
Customize the git author identity used for checkpoint commits. When not set, defaults to `fabro` / `fabro@local`.
|
||||
|
||||
|
|
@ -154,19 +160,23 @@ Customize the git author identity used for checkpoint commits. When not set, def
|
|||
| `name` | Git author name | `"fabro"` |
|
||||
| `email` | Git author email | `"fabro@local"` |
|
||||
|
||||
On same-machine setups, the CLI and server read the same `[git.author]`. On remote setups, the server uses its local `settings.toml`.
|
||||
### `[server.integrations.github]` section
|
||||
|
||||
### `[git.webhooks]` section
|
||||
Configure a GitHub App integration. Required fields include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`:
|
||||
|
||||
Enable automatic GitHub webhook delivery via Tailscale funnel. When configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256.
|
||||
```toml title="settings.toml"
|
||||
[server.integrations.github]
|
||||
app_id = "123456"
|
||||
client_id = "Iv1.abc123"
|
||||
slug = "fabro-app"
|
||||
|
||||
| Key | Description | Values |
|
||||
|---|---|---|
|
||||
| `strategy` | Webhook delivery method | `"tailscale_funnel"` |
|
||||
[server.integrations.github.webhooks]
|
||||
strategy = "tailscale_funnel"
|
||||
```
|
||||
|
||||
Requires a configured GitHub App (`[git]` section with `app_id` and `client_id`) and the `GITHUB_APP_WEBHOOK_SECRET` environment variable.
|
||||
When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable.
|
||||
|
||||
### `[checkpoint]` section
|
||||
### `[run.checkpoint]` section
|
||||
|
||||
Configure checkpoint behavior for all runs.
|
||||
|
||||
|
|
@ -174,7 +184,7 @@ Configure checkpoint behavior for all runs.
|
|||
|---|---|
|
||||
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) |
|
||||
|
||||
Exclude globs from `settings.toml` and run configs are merged (union, deduplicated). See [Run Configuration — Checkpoint](/execution/run-configuration#checkpoint) for per-run configuration.
|
||||
`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration.
|
||||
|
||||
### `[features]` section
|
||||
|
||||
|
|
@ -182,7 +192,6 @@ Toggle experimental or opt-in features. All features default to `false`.
|
|||
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
| `retros` | Enable automatic [retro](/execution/retros) generation after workflow runs (experimental) |
|
||||
| `session_sandboxes` | Enable session sandboxes in the web UI |
|
||||
|
||||
The same `[features]` section can be set in `fabro.toml` (project-level) to enable features per-project.
|
||||
|
|
|
|||
|
|
@ -152,25 +152,29 @@ If the server marks the result as an error (`is_error: true`), the tool result i
|
|||
A workflow that uses Playwright MCP to automate a browser inside a Daytona sandbox:
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
goal = "Test the login page"
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
[run]
|
||||
goal = "Test the login page"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "daytona-medium"
|
||||
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["screenshots/**"]
|
||||
|
||||
[mcp_servers.playwright]
|
||||
[run.agent.mcps.playwright]
|
||||
type = "sandbox"
|
||||
command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"]
|
||||
port = 3100
|
||||
startup_timeout_secs = 60
|
||||
tool_timeout_secs = 120
|
||||
startup_timeout = "60s"
|
||||
tool_timeout = "2m"
|
||||
```
|
||||
|
||||
After startup, the agent sees 22 Playwright tools including:
|
||||
|
|
|
|||
|
|
@ -4051,376 +4051,37 @@ components:
|
|||
|
||||
# ── Settings Schemas ─────────────────────────────────────────────────
|
||||
|
||||
RunSettings:
|
||||
description: Structured run settings mirroring fabro_types::Settings.
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
- graph
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Settings schema version.
|
||||
example: 1
|
||||
goal:
|
||||
type: string
|
||||
description: Goal description for the run.
|
||||
example: Diagnose and fix CI build failures
|
||||
graph:
|
||||
type: string
|
||||
description: Graphviz graph filename.
|
||||
example: fix_build.fabro
|
||||
work_dir:
|
||||
type: string
|
||||
description: Working directory for the run.
|
||||
llm:
|
||||
$ref: "#/components/schemas/LlmSettings"
|
||||
setup:
|
||||
$ref: "#/components/schemas/SetupSettings"
|
||||
sandbox:
|
||||
$ref: "#/components/schemas/SandboxSettings"
|
||||
vars:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Variable map for template expansion.
|
||||
hooks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/HookDefinition"
|
||||
|
||||
LlmSettings:
|
||||
description: LLM provider and model settings.
|
||||
type: object
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
description: Model identifier.
|
||||
example: claude-sonnet
|
||||
provider:
|
||||
type: string
|
||||
description: Provider name.
|
||||
example: anthropic
|
||||
fallbacks:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Provider fallback chains.
|
||||
|
||||
SetupSettings:
|
||||
description: Setup commands run before the workflow.
|
||||
type: object
|
||||
required:
|
||||
- commands
|
||||
properties:
|
||||
commands:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Shell commands to execute.
|
||||
timeout_ms:
|
||||
type: integer
|
||||
description: Timeout per command in milliseconds.
|
||||
|
||||
SandboxSettings:
|
||||
description: Sandbox execution environment settings.
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: Sandbox provider name.
|
||||
example: daytona
|
||||
preserve:
|
||||
type: boolean
|
||||
description: Whether to preserve the sandbox after the run.
|
||||
devcontainer:
|
||||
type: boolean
|
||||
description: Whether to use a devcontainer for the sandbox.
|
||||
daytona:
|
||||
$ref: "#/components/schemas/DaytonaSettings"
|
||||
local:
|
||||
$ref: "#/components/schemas/LocalSandboxSettings"
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Environment variables injected into the sandbox.
|
||||
|
||||
LocalSandboxSettings:
|
||||
description: Local sandbox settings.
|
||||
type: object
|
||||
properties:
|
||||
worktree_mode:
|
||||
type: string
|
||||
description: Git worktree mode for local sandbox.
|
||||
enum: [always, clean, dirty, never]
|
||||
default: clean
|
||||
|
||||
DaytonaSettings:
|
||||
description: Daytona-specific sandbox settings.
|
||||
type: object
|
||||
properties:
|
||||
auto_stop_interval:
|
||||
type: integer
|
||||
description: Auto-stop interval in seconds.
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Labels applied to the sandbox.
|
||||
snapshot:
|
||||
$ref: "#/components/schemas/DaytonaSnapshotSettings"
|
||||
network:
|
||||
description: "Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}."
|
||||
oneOf:
|
||||
- type: string
|
||||
enum:
|
||||
- block
|
||||
- allow_all
|
||||
- type: object
|
||||
required:
|
||||
- allow_list
|
||||
properties:
|
||||
allow_list:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: CIDR allowlist for network access.
|
||||
skip_clone:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Skip git repo detection and cloning during initialization.
|
||||
|
||||
DaytonaSnapshotSettings:
|
||||
description: Snapshot configuration for Daytona sandboxes.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Snapshot name.
|
||||
cpu:
|
||||
type: integer
|
||||
description: CPU cores.
|
||||
memory:
|
||||
type: integer
|
||||
description: Memory in GB.
|
||||
disk:
|
||||
type: integer
|
||||
description: Disk in GB.
|
||||
dockerfile:
|
||||
type: string
|
||||
description: Dockerfile content for snapshot creation.
|
||||
|
||||
HookDefinition:
|
||||
description: |
|
||||
A single hook definition. The type discriminator and variant fields are flattened into this object.
|
||||
|
||||
Field-to-type mapping:
|
||||
- `command`: requires `command`
|
||||
- `http`: requires `url`; optional `headers`, `allowed_env_vars`, `tls`
|
||||
- `prompt`: requires `prompt`; optional `model`
|
||||
- `agent`: requires `prompt`; optional `model`, `max_tool_rounds`
|
||||
|
||||
Top-level `command` without `type` is shorthand for type=command.
|
||||
type: object
|
||||
required:
|
||||
- event
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable hook name.
|
||||
event:
|
||||
type: string
|
||||
description: Event that triggers this hook.
|
||||
enum:
|
||||
- run_start
|
||||
- run_complete
|
||||
- stage_start
|
||||
- stage_complete
|
||||
command:
|
||||
type: string
|
||||
description: Shell command (shorthand for type=command).
|
||||
type:
|
||||
type: string
|
||||
description: Hook execution type.
|
||||
enum:
|
||||
- command
|
||||
- http
|
||||
- prompt
|
||||
- agent
|
||||
url:
|
||||
type: string
|
||||
description: URL for HTTP hooks.
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Headers for HTTP hooks.
|
||||
allowed_env_vars:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Environment variables allowed in HTTP hook headers.
|
||||
tls:
|
||||
type: string
|
||||
description: TLS verification mode for HTTP hooks.
|
||||
enum:
|
||||
- verify
|
||||
- no_verify
|
||||
- "off"
|
||||
prompt:
|
||||
type: string
|
||||
description: Prompt text for prompt/agent hooks.
|
||||
model:
|
||||
type: string
|
||||
description: Model for prompt/agent hooks.
|
||||
max_tool_rounds:
|
||||
type: integer
|
||||
description: Max tool rounds for agent hooks.
|
||||
matcher:
|
||||
type: string
|
||||
description: Regex matched against node_id or handler_type.
|
||||
blocking:
|
||||
type: boolean
|
||||
description: Whether this hook blocks execution.
|
||||
timeout_ms:
|
||||
type: integer
|
||||
description: Timeout in milliseconds.
|
||||
sandbox:
|
||||
type: boolean
|
||||
description: Whether hook runs in sandbox.
|
||||
|
||||
ServerSettings:
|
||||
description: Structured server settings mirroring fabro_types::Settings.
|
||||
description: |
|
||||
Non-secret view of the server's effective v2 settings.
|
||||
|
||||
Wire shape mirrors `fabro_types::settings::SettingsFile` with the
|
||||
secret-bearing subtrees dropped before serialization:
|
||||
|
||||
- `server.listen.*` (bind address, TLS key material)
|
||||
- `server.auth.api.{jwt,mtls}` internals
|
||||
- `server.artifacts.s3` / `server.slatedb.s3` credentials
|
||||
- `server.integrations.github.webhooks`, Slack/Discord/Teams tokens
|
||||
- Every `${env.NAME}` InterpString is serialized in its unresolved
|
||||
template form, never the resolved secret value.
|
||||
|
||||
The top-level object keys follow the v2 schema: `_version`, `project`,
|
||||
`workflow`, `run`, `cli`, `server`, `features`.
|
||||
|
||||
See `lib/crates/fabro-types/src/settings/tree.rs` for the full type.
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Settings schema version.
|
||||
goal:
|
||||
type: string
|
||||
description: Default goal description.
|
||||
goal_file:
|
||||
type: string
|
||||
description: Path to a goal file.
|
||||
graph:
|
||||
type: string
|
||||
description: Default Graphviz graph path.
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Default label map.
|
||||
server:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
type: string
|
||||
description: Default server target for CLI commands.
|
||||
tls:
|
||||
type: object
|
||||
properties:
|
||||
cert:
|
||||
type: string
|
||||
description: Client certificate path.
|
||||
key:
|
||||
type: string
|
||||
description: Client key path.
|
||||
ca:
|
||||
type: string
|
||||
description: Certificate authority path.
|
||||
exec:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: Default exec provider.
|
||||
model:
|
||||
type: string
|
||||
description: Default exec model.
|
||||
permissions:
|
||||
type: string
|
||||
enum: [read-only, read-write, full]
|
||||
description: Exec permission level.
|
||||
output_format:
|
||||
type: string
|
||||
enum: [text, json]
|
||||
description: Exec output format.
|
||||
prevent_idle_sleep:
|
||||
type: boolean
|
||||
description: Prevent system idle sleep while running.
|
||||
verbose:
|
||||
type: boolean
|
||||
description: Enable verbose output by default.
|
||||
upgrade_check:
|
||||
type: boolean
|
||||
description: Whether upgrade checks are enabled.
|
||||
dry_run:
|
||||
type: boolean
|
||||
description: Default dry-run mode.
|
||||
auto_approve:
|
||||
type: boolean
|
||||
description: Default auto-approve mode.
|
||||
no_retro:
|
||||
type: boolean
|
||||
description: Skip retro generation by default.
|
||||
storage_dir:
|
||||
type: string
|
||||
description: Storage directory path.
|
||||
max_concurrent_runs:
|
||||
type: integer
|
||||
description: Maximum concurrent runs.
|
||||
web:
|
||||
$ref: "#/components/schemas/WebSettings"
|
||||
api:
|
||||
$ref: "#/components/schemas/ApiSettings"
|
||||
git:
|
||||
$ref: "#/components/schemas/GitSettings"
|
||||
features:
|
||||
$ref: "#/components/schemas/Features"
|
||||
log:
|
||||
$ref: "#/components/schemas/LogSettings"
|
||||
work_dir:
|
||||
type: string
|
||||
description: Default working directory.
|
||||
llm:
|
||||
$ref: "#/components/schemas/LlmSettings"
|
||||
setup:
|
||||
$ref: "#/components/schemas/SetupSettings"
|
||||
sandbox:
|
||||
$ref: "#/components/schemas/SandboxSettings"
|
||||
vars:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Default variable map.
|
||||
checkpoint:
|
||||
$ref: "#/components/schemas/CheckpointSettings"
|
||||
pull_request:
|
||||
$ref: "#/components/schemas/PullRequestSettings"
|
||||
hooks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/HookDefinition"
|
||||
artifacts:
|
||||
$ref: "#/components/schemas/ArtifactsSettings"
|
||||
mcp_servers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/McpServerEntry"
|
||||
description: Default MCP server configurations.
|
||||
github:
|
||||
$ref: "#/components/schemas/GitHubSettings"
|
||||
fabro:
|
||||
type: object
|
||||
properties:
|
||||
root:
|
||||
type: string
|
||||
description: Project fabro root directory.
|
||||
additionalProperties: true
|
||||
|
||||
RunSettings:
|
||||
description: |
|
||||
The merged, persisted v2 `[run]` subtree for a specific run, serialized
|
||||
as the wrapping `SettingsFile` shape (so `settings.run.*` holds the run
|
||||
config). Matches `fabro_types::settings::SettingsFile` minus secret
|
||||
subtrees, identical to ServerSettings' redaction rules.
|
||||
|
||||
See `lib/crates/fabro-types/src/settings/run.rs` for the full type.
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SystemInfoResponse:
|
||||
description: Runtime information for the active Fabro server process.
|
||||
|
|
@ -4620,216 +4281,6 @@ components:
|
|||
format: int64
|
||||
description: Bytes used by the run scratch directory.
|
||||
|
||||
GitHubSettings:
|
||||
description: GitHub App token injection configuration.
|
||||
type: object
|
||||
properties:
|
||||
permissions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: GitHub API permissions to request (e.g. contents = write).
|
||||
|
||||
McpServerEntry:
|
||||
description: MCP server connection entry.
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: Transport type (stdio or http).
|
||||
command:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Command and arguments for stdio transport.
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Environment variables for stdio transport.
|
||||
url:
|
||||
type: string
|
||||
description: URL for http transport.
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: HTTP headers for http transport.
|
||||
startup_timeout_secs:
|
||||
type: integer
|
||||
description: Startup timeout in seconds.
|
||||
tool_timeout_secs:
|
||||
type: integer
|
||||
description: Tool call timeout in seconds.
|
||||
|
||||
ArtifactsSettings:
|
||||
description: Artifact collection configuration.
|
||||
type: object
|
||||
properties:
|
||||
include:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Glob patterns for files to collect as run artifacts.
|
||||
|
||||
LogSettings:
|
||||
description: Logging configuration.
|
||||
type: object
|
||||
properties:
|
||||
level:
|
||||
type: string
|
||||
description: Log level (e.g. trace, debug, info).
|
||||
|
||||
CheckpointSettings:
|
||||
description: Checkpoint configuration for file exclusion.
|
||||
type: object
|
||||
properties:
|
||||
exclude_globs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Glob patterns to exclude from checkpoints.
|
||||
|
||||
PullRequestSettings:
|
||||
description: Pull request creation configuration.
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether to create a pull request after a successful run.
|
||||
draft:
|
||||
type: boolean
|
||||
description: Whether to create the pull request as a draft.
|
||||
auto_merge:
|
||||
type: boolean
|
||||
description: Whether to enable GitHub auto-merge on the created PR. Implies draft = false.
|
||||
merge_strategy:
|
||||
type: string
|
||||
enum: [squash, merge, rebase]
|
||||
description: Merge strategy for auto-merge.
|
||||
|
||||
WebSettings:
|
||||
description: Web UI configuration.
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the embedded web UI and browser-oriented routes are enabled.
|
||||
url:
|
||||
type: string
|
||||
description: Web UI URL.
|
||||
auth:
|
||||
$ref: "#/components/schemas/AuthSettings"
|
||||
|
||||
AuthSettings:
|
||||
description: Authentication configuration.
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: Auth provider.
|
||||
enum:
|
||||
- github
|
||||
- insecure_disabled
|
||||
allowed_usernames:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Allowed usernames.
|
||||
|
||||
ApiSettings:
|
||||
description: API server configuration.
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
description: API base URL.
|
||||
authentication_strategies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- jwt
|
||||
- mtls
|
||||
description: Authentication strategies.
|
||||
tls:
|
||||
$ref: "#/components/schemas/TlsSettings"
|
||||
|
||||
TlsSettings:
|
||||
description: TLS certificate configuration.
|
||||
type: object
|
||||
required:
|
||||
- cert
|
||||
- key
|
||||
- ca
|
||||
properties:
|
||||
cert:
|
||||
type: string
|
||||
description: Certificate file path.
|
||||
key:
|
||||
type: string
|
||||
description: Key file path.
|
||||
ca:
|
||||
type: string
|
||||
description: CA certificate file path.
|
||||
|
||||
GitSettings:
|
||||
description: Git provider configuration.
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: Git provider.
|
||||
enum:
|
||||
- github
|
||||
app_id:
|
||||
type: string
|
||||
description: GitHub App ID.
|
||||
client_id:
|
||||
type: string
|
||||
description: GitHub App Client ID.
|
||||
slug:
|
||||
type: string
|
||||
description: GitHub App slug.
|
||||
author:
|
||||
$ref: "#/components/schemas/GitAuthorSettings"
|
||||
webhooks:
|
||||
$ref: "#/components/schemas/WebhookSettings"
|
||||
|
||||
GitAuthorSettings:
|
||||
description: Git commit author configuration.
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Author name for commits.
|
||||
email:
|
||||
type: string
|
||||
description: Author email for commits.
|
||||
|
||||
WebhookSettings:
|
||||
description: Webhook delivery configuration.
|
||||
type: object
|
||||
required:
|
||||
- strategy
|
||||
properties:
|
||||
strategy:
|
||||
type: string
|
||||
description: Webhook delivery strategy.
|
||||
enum:
|
||||
- tailscale_funnel
|
||||
|
||||
Features:
|
||||
description: Feature flags.
|
||||
type: object
|
||||
properties:
|
||||
session_sandboxes:
|
||||
type: boolean
|
||||
description: Enable session sandboxes.
|
||||
retros:
|
||||
type: boolean
|
||||
description: "Experimental: enable automatic retro generation after workflow runs."
|
||||
|
||||
# ── Discovery Schemas ────────────────────────────────────────────────
|
||||
|
||||
RootResponseUrls:
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ http://localhost:3000/api/v1
|
|||
The base URL is configurable via `settings.toml`:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[api]
|
||||
base_url = "https://fabro.example.com/api/v1"
|
||||
[server.api]
|
||||
url = "https://fabro.example.com/api/v1"
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
|
@ -29,8 +29,8 @@ base_url = "https://fabro.example.com/api/v1"
|
|||
The API supports two authentication strategies, configured in `settings.toml`:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[api]
|
||||
authentication_strategies = ["jwt"]
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### JWT (Bearer Token)
|
||||
|
|
@ -56,13 +56,17 @@ Set the verification key via the `FABRO_JWT_PUBLIC_KEY` environment variable (PE
|
|||
|
||||
### mTLS (Mutual TLS)
|
||||
|
||||
With mTLS, the client authenticates using a TLS client certificate. Configure both the strategy and TLS paths:
|
||||
With mTLS, the client authenticates using a TLS client certificate. Configure the strategy and shared listener TLS:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[api]
|
||||
authentication_strategies = ["mtls"]
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
|
||||
[api.tls]
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "0.0.0.0:3000"
|
||||
|
||||
[server.listen.tls]
|
||||
cert = "~/.fabro/certs/server.crt"
|
||||
key = "~/.fabro/certs/server.key"
|
||||
ca = "~/.fabro/certs/ca.crt"
|
||||
|
|
@ -72,11 +76,14 @@ The Common Name (CN) from the client certificate identifies the user.
|
|||
|
||||
### Multiple Strategies
|
||||
|
||||
You can configure both strategies. They are tried in order — the first successful match wins:
|
||||
You can enable both strategies. They are tried in order — the first successful match wins:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[api]
|
||||
authentication_strategies = ["jwt", "mtls"]
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
---
|
||||
date: 2026-04-08
|
||||
topic: settings-toml-redesign
|
||||
---
|
||||
|
||||
# Settings TOML Redesign
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Fabro has three layered TOML config files:
|
||||
|
||||
- `~/.fabro/settings.toml` for machine defaults
|
||||
- `fabro.toml` for project defaults
|
||||
- `workflow.toml` for workflow-local defaults
|
||||
|
||||
All three layer into one unified settings object. In same-host setups, the CLI and server may both read `~/.fabro/settings.toml`. In split-host setups, the CLI host and server host each read their own local `settings.toml` and consume only the sections relevant to that process.
|
||||
|
||||
The current config shape grew organically. It now has naming drift, mixed ownership boundaries, uneven merge semantics, and several top-level sections that no longer reflect a clean mental model. Fabro is still greenfield with no deployed compatibility burden, so this is the right time to make a hard cut and establish a coherent, future-proof config language.
|
||||
|
||||
The new design must optimize for:
|
||||
|
||||
- a small, elegant top-level structure
|
||||
- coherent ownership boundaries between run, CLI, server, project, and workflow concerns
|
||||
- paste-anywhere ergonomics across the three config files
|
||||
- explicit and predictable layering semantics
|
||||
- future provider growth without provider-specific sprawl in the core model
|
||||
|
||||
## Requirements
|
||||
|
||||
**Config language and layering**
|
||||
|
||||
- R1. `settings.toml`, `fabro.toml`, and `workflow.toml` must share the same schema. Files differ by precedence only, not by allowed sections.
|
||||
- R2. Any config section may appear in any Fabro TOML file. Consumers must ignore sections they do not use.
|
||||
- R3. The top-level schema must be strictly namespaced. The only top-level config domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`, plus reserved underscore-prefixed meta keys.
|
||||
- R4. The schema version key must be `_version`, not `version`.
|
||||
- R5. Underscore-prefixed keys are reserved only at the top level for config-language metadata. Nested underscore keys are not part of the language.
|
||||
- R6. The config language must not add a general unset mechanism in this pass.
|
||||
- R7. Unknown config keys against the full union schema must be hard errors. This is schema validation, not consumer-specific validation.
|
||||
- R8. Duplicate keys and duplicate hook `id` values within the same file must be hard errors.
|
||||
|
||||
**Object model and namespace boundaries**
|
||||
|
||||
- R9. `[workflow]` and `[run]` must be sibling top-level sections. Do not nest `[workflow.run]`.
|
||||
- R10. `[workflow]` is descriptive for now. It must support first-class fields such as `name`, `description`, optional `graph`, and `metadata`. Structured workflow inputs are deferred.
|
||||
- R11. `workflow.toml` remains the canonical workflow config filename. The default graph file remains `workflow.fabro`, with optional `[workflow].graph` override.
|
||||
- R12. `[project]` must be a first-class project object with fields such as `name`, `description`, `directory`, and `metadata`.
|
||||
- R13. `project.directory` replaces the old Fabro project root concept and means the Fabro-managed project directory inside the repo, defaulting to `fabro/`.
|
||||
- R14. Workflow discovery remains conventional: `<project.directory>/workflows/<name>/workflow.toml`. Do not add a separate configurable workflows directory.
|
||||
- R15. `[run]` is the shared execution domain. It may appear in all three files and layer normally.
|
||||
- R16. `[cli]` and `[server]` are owner-first process domains. Settings belong to the process that reads them, not to whether the host is “local” or “remote.” For trust-boundary reasons, CLI and server processes consume their owner-specific sections only from the local `~/.fabro/settings.toml` plus explicit process-local overrides. Same-shaped `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert for those processes.
|
||||
- R17. `[features]` is a reserved cross-cutting namespace for Fabro capability flags only. It must have a high admission bar and must not become a junk drawer.
|
||||
- R18. Logging is process-owned. Use `[cli.logging]` and `[server.logging]`; do not keep a shared logging section.
|
||||
|
||||
**Run model**
|
||||
|
||||
- R19. `[run]` must keep a small direct manifest surface for cross-cutting run fields such as `goal` and `working_dir`.
|
||||
- R20. `working_dir` replaces `work_dir`.
|
||||
- R21. `metadata` replaces Fabro-owned `labels` and exists on `project`, `workflow`, and `run` as flat string-to-string maps.
|
||||
- R22. `run.inputs` replaces `vars`. `run.inputs` must accept TOML scalar values. `metadata` remains string-to-string. `run.inputs` intentionally replaces the full inherited map rather than merging by key.
|
||||
- R23. `[run.model]` is the default model selection surface for LLM-backed workflow stages. `[run.agent]` is only for agent-specific settings.
|
||||
- R24. `[run.agent]` owns agent-only knobs such as `permissions` and `mcps`. `[run.sandbox]` owns the sandbox selection and execution-environment surface, including `provider`, shared sandbox knobs, `env`, and provider-specific nested tables.
|
||||
- R25. `run.agent.permissions` must remain a simple enum string, not an object.
|
||||
- R26. `[run.git]` and `[run.scm]` must remain separate concepts. `git` is local Git behavior such as commit author; `scm` is remote host/provider behavior.
|
||||
- R27. `[run.pull_request]` remains the provider-neutral run surface for PR behavior.
|
||||
- R28. `[run.prepare]` is the run preparation surface and replaces the old `setup` naming.
|
||||
- R29. `run.prepare` must be an ordered list of steps at `[[run.prepare.steps]]`.
|
||||
- R30. `run.prepare.steps` replaces as a whole ordered list across layers.
|
||||
- R31. `[run.execution]` groups run-conduct knobs such as `mode`, `approval`, and `retros`. In the first pass, `mode` is `normal | dry_run`, `approval` is `prompt | auto`, and `retros` is a positive-form boolean. Do not keep negated or ambiguous booleans like `no_retro`.
|
||||
- R32. `[run.checkpoint]` remains its own run domain. `[[run.hooks]]` is the ordered run-hook surface for run lifecycle automation.
|
||||
- R33. `[run.artifacts]` defines what run artifacts are collected. Server-side artifact storage is separate.
|
||||
- R34. `[run.notifications.<name>]` is a keyed set of named notification routes. Notification routes merge by field across layers and support `enabled = false`.
|
||||
- R35. `[run.interviews]` is a single optional external/default interview delivery surface. HTTP/API answering is always available and is not modeled as an interview provider.
|
||||
- R36. Notification and interview event selection must use raw Fabro event names, not a second notification-specific vocabulary.
|
||||
|
||||
**CLI model**
|
||||
|
||||
- R37. CLI target resolution lives under `[cli.target]`, not `[server]` or `[cli.remote]`.
|
||||
- R38. CLI target transport must be explicit with `type = "http" | "unix"` and transport-specific fields, not overloaded scheme strings.
|
||||
- R39. CLI transport TLS lives under `[cli.target.tls]`.
|
||||
- R40. CLI auth is a separate domain at `[cli.auth]`, with explicit `strategy` selection. `strategy = "none"` explicitly disables inherited auth.
|
||||
- R41. `fabro exec` defaults live under `[cli.exec]`, with `[cli.exec.model]` and `[cli.exec.agent]` split cleanly.
|
||||
- R42. Generic CLI output defaults live under `[cli.output]`, not under `exec`.
|
||||
- R43. Upgrade checks live under `[cli.updates]`.
|
||||
- R44. Idle sleep prevention lives under `[cli.exec]`.
|
||||
|
||||
**Server model**
|
||||
|
||||
- R45. `[server]` is a namespace container. Actual settings live in named subdomains.
|
||||
- R46. The server binds the API and web surfaces on one shared listener. Bind transport must live under `[server.listen]`, not separately under `[server.api]` and `[server.web]`.
|
||||
- R47. `[server.listen]` must use explicit transport types such as `tcp` and `unix`.
|
||||
- R48. Shared listener TLS must live under `[server.listen.tls]`.
|
||||
- R49. `[server.api]` holds only API-surface settings such as public URL, not bind/auth/TLS settings.
|
||||
- R50. `[server.web]` holds only web-surface settings such as `enabled` and public URL, not auth settings.
|
||||
- R51. Server auth is a cohesive domain at `[server.auth]`.
|
||||
- R52. `[server.auth.api]` must support multiple strategies concurrently.
|
||||
- R53. `[server.auth.web]` must support multiple providers concurrently via `[server.auth.web.providers.<provider>]`.
|
||||
- R54. Web-auth access rules remain provider-neutral on `[server.auth.web]`; provider-specific config lives under each provider subtable.
|
||||
- R55. Web-auth providers must support `enabled = true|false` to disable inherited provider config cleanly.
|
||||
- R56. Inbound provider webhooks belong under provider integrations such as `[server.integrations.github.webhooks]`, not under generic server auth or web sections.
|
||||
- R57. `[server.storage]` refers only to a managed local disk root on the host. It must expose a single managed `root`.
|
||||
- R58. `[server.artifacts]` is separate from `[server.storage]` and is backed by an object store provider.
|
||||
- R59. `[server.slatedb]` is separate from both `[server.storage]` and `[server.artifacts]`. It is backed by its own object store provider and may include database-specific tunables such as `flush_interval`.
|
||||
- R60. `[server.scheduler]` owns server-managed execution policy such as concurrency limits. It must not compete with `[run]`.
|
||||
|
||||
**Provider and future-proofing rules**
|
||||
|
||||
- R61. Core Fabro concepts should be provider-neutral. Provider-specific details should live in provider-specific nested tables where the domain genuinely requires them.
|
||||
- R62. Sandbox config must remain provider-specific because provider differences are too large to hide behind one flat abstraction.
|
||||
- R63. Model config must remain intentionally provider-neutral. It should not grow provider-specific subtables. `run.model.fallbacks` is a single ordered array of model references. Each entry may be a bare provider token such as `openai`, a bare model alias or model id such as `gpt-5.4`, or a qualified reference such as `gemini/gemini-flash`. Bare references are allowed only when unambiguous. Ambiguous bare references must hard-error and require qualification. A bare provider token means “choose the best matching model from that provider.”
|
||||
- R64. SCM config must be provider-neutral at the core (`[run.scm]`) with room for provider-specific nested tables such as `[run.scm.github]` only where necessary.
|
||||
- R65. Chat platforms such as Slack, Discord, and Teams are integrations. Their server-owned setup lives under `[server.integrations.<provider>]`; run behavior lives under `[run.notifications.*]` and `[run.interviews]`.
|
||||
- R66. Object-store-backed domains must use a shared pattern: a small provider-neutral envelope plus provider-specific nested tables.
|
||||
- R67. For local object-store providers, default to `server.storage.root`, but allow explicit local override roots when needed.
|
||||
|
||||
**Merge, validation, and runtime semantics**
|
||||
|
||||
- R68. Scalars replace.
|
||||
- R69. Structured tables merge by field.
|
||||
- R70. Freeform maps replace by default.
|
||||
- R71. A small, explicit set of maps may merge by key where additive inheritance is the least surprising behavior, including `run.sandbox.env` and provider-native maps such as `run.sandbox.daytona.labels`. These maps are intentionally sticky in v1: higher-precedence layers may overwrite keys but cannot remove inherited keys.
|
||||
- R72. Arrays replace by default.
|
||||
- R73. Arrays must support splice semantics via `...` in declared splice-capable string arrays, for example `["...", "c"]` for append and `["a", "..."]` for prepend. At most one exact `"..."` marker may appear per array. In the base layer with no inherited parent, the splice marker resolves to an empty inherited segment. In splice-capable arrays, the literal string value `"..."` is reserved and may not be used as data.
|
||||
- R74. Security and policy lists must replace by default and only inherit via explicit `...`.
|
||||
- R75. Keyed named objects such as notifications, MCPs, and web-auth providers must merge by field across layers. User-defined keyed object names in namespaces that also host provider-specific subtables must not equal built-in provider identifiers, to avoid ambiguous shapes such as `[run.notifications.slack.slack]`.
|
||||
- R76. Named keyed objects that may need to be disabled must support `enabled = false`.
|
||||
- R77. `[[run.hooks]]` remains an ordered list. Hooks may define an optional `id`; `name` remains human-facing only. Hooks without `id` append. Hooks with the same `id` replace whole entries in place. Hooks without `id` from a higher-precedence layer append after the fully merged inherited hook list, preserving per-file declaration order. Hook ordering remains significant.
|
||||
- R78. Provider-specific required fields should only be validated when that provider/section is actually consumed.
|
||||
- R79. Unresolved `${env.NAME}` references should only error when the field is actually consumed.
|
||||
- R80. The config language must not require separate validation modes for CLI, server, and run config in this pass. Runtime consumption drives context-specific validation.
|
||||
|
||||
**String interpolation and value formats**
|
||||
|
||||
- R81. Any string field may use `${env.NAME}` interpolation, either as the whole value or as a substring inside a larger string. Multiple `${env.NAME}` tokens may appear in the same string.
|
||||
- R82. Do not support config-to-config references such as `${run.inputs.foo}` in this pass.
|
||||
- R83. All time-like values should use human-readable durations such as `"30s"`, `"1m"`, or `"1h"`, not `_ms` or `_secs` fields.
|
||||
- R84. Memory and disk settings should accept generous human-readable size syntax. Bare values such as `8`, plus `8G`, `8GB`, and `8GiB`, should all parse successfully.
|
||||
- R85. Docs and examples should use `GB` as the canonical style. Parsing should remain generous.
|
||||
- R86. CPU remains an integer core count.
|
||||
|
||||
**Command execution shape**
|
||||
|
||||
- R87. Shell-evaluated actions use `script = "..."`.
|
||||
- R88. Direct process launches use `command = ["..."]`.
|
||||
- R89. `script` and `command` are mutually exclusive.
|
||||
- R90. The `script` xor `command` rule must apply consistently across prepare steps, hooks, and MCP transports that launch a local process. Non-launching MCP transports such as plain HTTP do not use either field.
|
||||
|
||||
## Precedence and Override Order
|
||||
|
||||
The config language has one schema but two consumption models.
|
||||
|
||||
Shared layered domains such as `[project]`, `[workflow]`, `[run]`, and `[features]` use this override order:
|
||||
|
||||
1. Explicit process-local command args or flags
|
||||
2. Explicit process-local environment override channels, where Fabro defines them
|
||||
3. `workflow.toml`
|
||||
4. `fabro.toml`
|
||||
5. `~/.fabro/settings.toml`
|
||||
6. Built-in defaults
|
||||
|
||||
Owner-specific process domains use a narrower trust boundary:
|
||||
|
||||
1. Explicit process-local command args or flags
|
||||
2. Explicit process-local environment override channels, where Fabro defines them
|
||||
3. `~/.fabro/settings.toml`
|
||||
4. Built-in defaults
|
||||
|
||||
Additional rules:
|
||||
|
||||
- String interpolation via `${env.NAME}` is not a separate precedence layer. It is value resolution inside the winning layered config value.
|
||||
- Server start flags override only the server-consumed settings for that process invocation. They do not change persisted TOML values.
|
||||
- CLI flags override only the CLI-consumed settings for that process invocation.
|
||||
- `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain parseable but are not part of runtime precedence for those processes.
|
||||
- If a future env override channel exists for a setting, it must sit between explicit args/flags and layered TOML.
|
||||
|
||||
## Validation Boundary
|
||||
|
||||
Schema validation and runtime validation are separate concerns:
|
||||
|
||||
- All config files validate against the full union schema before consumer-specific filtering.
|
||||
- Unknown-key validation and duplicate-key validation run at schema-validation time, not at consumer-specific runtime.
|
||||
- Lazy validation applies only to provider-specific required fields, selected strategies/providers, and `${env.NAME}` resolution for fields that a consumer actually uses.
|
||||
- Unused but schema-valid `cli.*` and `server.*` stanzas in lower-trust files remain inert rather than invalid.
|
||||
|
||||
## Disable Semantics
|
||||
|
||||
The config language must use one explicit rule for inherited config suppression:
|
||||
|
||||
- Absence means inherit or express no opinion.
|
||||
- `enabled = false` disables inherited keyed named objects such as notification routes, MCP entries, and web-auth providers.
|
||||
- `provider = "none"` or `strategy = "none"` disables inherited singleton selectable sections such as interviews or auth.
|
||||
- Disabled sections suppress provider-specific required-field validation for their disabled subtree.
|
||||
|
||||
## Public URL Semantics
|
||||
|
||||
`server.listen` is only the bind transport. It must not be treated as a public URL source.
|
||||
|
||||
- `server.api.url` and `server.web.url` are optional public URLs.
|
||||
- They are not derived from `server.listen`.
|
||||
- They are not derived from each other.
|
||||
- If omitted, Fabro must treat the corresponding public URL as unspecified rather than synthesizing one implicitly.
|
||||
|
||||
## Normative Merge Matrix
|
||||
|
||||
This redesign should specify exact merge behavior for the first-pass config surface rather than relying only on structural categories.
|
||||
|
||||
| Path | Merge behavior |
|
||||
|---|---|
|
||||
| `project` direct scalar fields such as `name`, `description`, and `directory` | replace by field |
|
||||
| `project.metadata` | replace |
|
||||
| `workflow` direct scalar fields such as `name`, `description`, and `graph` | replace by field |
|
||||
| `workflow.metadata` | replace |
|
||||
| `run` direct scalar fields such as `goal` and `working_dir` | replace by field |
|
||||
| `run.metadata` | replace |
|
||||
| `run.inputs` | replace |
|
||||
| `run.model` direct scalar fields such as `provider` and `name` | replace by field |
|
||||
| `run.model.fallbacks` | replace, with `...` splice support |
|
||||
| `run.git.author` | merge by field |
|
||||
| `run.execution` | merge by field |
|
||||
| `run.checkpoint` | merge by field |
|
||||
| `run.sandbox` direct scalar fields such as `provider` and `preserve` | merge by field |
|
||||
| `run.sandbox.<provider>` | merge by field |
|
||||
| `run.sandbox.env` | merge by key |
|
||||
| provider-native maps such as `run.sandbox.daytona.labels` | merge by key |
|
||||
| notification route `events` arrays | replace, with `...` splice support |
|
||||
| `run.pull_request` | merge by field |
|
||||
| `run.interviews` | merge by field |
|
||||
| `run.interviews.<provider>` | merge by field |
|
||||
| `run.prepare.steps` | replace whole ordered list |
|
||||
| `run.notifications.<name>` | merge by field |
|
||||
| `run.notifications.<name>.<provider>` | merge by field |
|
||||
| `run.agent.mcps.<name>` | merge by field |
|
||||
| `cli.target` | merge by field |
|
||||
| `cli.auth` | merge by field |
|
||||
| `cli.exec` | merge by field |
|
||||
| `cli.exec.model` | merge by field |
|
||||
| `cli.exec.agent` | merge by field |
|
||||
| `cli.output` | merge by field |
|
||||
| `cli.updates` | merge by field |
|
||||
| `server.listen` | merge by field |
|
||||
| `server.api` | merge by field |
|
||||
| `server.web` | merge by field |
|
||||
| `server.auth.api` | merge by field |
|
||||
| `server.auth.api.<strategy>` | merge by field |
|
||||
| `server.auth.web.providers.<name>` | merge by field |
|
||||
| `server.storage` | merge by field |
|
||||
| `server.artifacts` | merge by field |
|
||||
| `server.artifacts.<provider>` | merge by field |
|
||||
| `server.slatedb` | merge by field |
|
||||
| `server.slatedb.<provider>` | merge by field |
|
||||
| `server.scheduler` | merge by field |
|
||||
| `[[run.hooks]]` | ordered list with special optional-`id` replacement rule |
|
||||
|
||||
New config paths added later should declare one of these behaviors explicitly in docs and implementation. Do not let merge behavior be accidental from Rust type shape alone.
|
||||
|
||||
## Canonical Rendering
|
||||
|
||||
Fabro should parse generously but render consistently in docs and config-inspection output.
|
||||
|
||||
- Durations should render in human-readable form such as `30s`, `1m`, or `1h`.
|
||||
- Memory and disk should render using `GB` in user-facing examples and normalized output.
|
||||
- `fabro settings` or equivalent config-inspection output should emit canonicalized values rather than the user's original alternate spelling when values have been normalized internally.
|
||||
- `fabro settings` or equivalent config-inspection output must redact values that were sourced from `${env.NAME}` by default, rather than printing the resolved secret-bearing value verbatim.
|
||||
|
||||
## Object Store Credential Semantics
|
||||
|
||||
First-pass object store configuration must work without a Fabro-specific secret reference language.
|
||||
|
||||
- Object store providers may rely on provider-native ambient auth such as IAM roles, workload identity, local credential files, or equivalent external mechanisms.
|
||||
- Provider-specific object-store config fields may also take ordinary string values populated via `${env.NAME}`.
|
||||
- This redesign does not add `${secret.NAME}` or a separate secret-backend reference syntax.
|
||||
|
||||
## Executable Config Trust Boundary
|
||||
|
||||
Config-executed actions are part of Fabro's trusted configuration model, not the agent permission model.
|
||||
|
||||
- `script` and `command` in prepare steps, hooks, and launching MCP transports are executable configuration, not passive metadata.
|
||||
- These actions execute under the trust boundary of the consuming process.
|
||||
- They are not mediated by `run.agent.permissions` or `cli.exec.agent.permissions`.
|
||||
- Users should treat `fabro.toml` and `workflow.toml` as executable project configuration, not as untrusted data blobs.
|
||||
|
||||
## Migration and Failure Behavior
|
||||
|
||||
This is a hard-cut redesign, but migration still needs explicit failure semantics.
|
||||
|
||||
- Missing `_version` defaults to `1` in the first pass.
|
||||
- `_version` values higher than the parser supports must hard-fail with an upgrade hint before deeper validation continues.
|
||||
- The legacy top-level `version` key must hard-fail with a targeted rename hint to `_version`.
|
||||
- Historical keys and obsolete top-level shapes should hard-fail rather than silently aliasing forward.
|
||||
- Error messages should point to the new replacement path whenever the replacement is known.
|
||||
- Historical file names that are no longer read should fail or warn deterministically with a rename hint.
|
||||
- There should be no silent compatibility layer that keeps old and new shapes both alive indefinitely.
|
||||
- Migration guidance must explicitly call out that the new default `project.directory = "fabro/"` changes workflow discovery relative to the old implicit project-root behavior.
|
||||
- Historical string command forms such as `command = "cargo fmt"` must migrate to either `script = "cargo fmt"` or `command = ["cargo", "fmt"]`.
|
||||
- Historical hook `name` remains display-only in the new language. Cross-layer hook replacement uses the optional `id` field, so users must add `id` explicitly where merge identity is intended.
|
||||
|
||||
Known first-pass migration mappings:
|
||||
|
||||
| Old shape | New shape |
|
||||
|---|---|
|
||||
| `version = 1` | `_version = 1` |
|
||||
| top-level `goal` | `[run].goal` |
|
||||
| top-level `work_dir` or `directory` | `[run].working_dir` |
|
||||
| top-level `labels` | `[run.metadata]` |
|
||||
| `[vars]` | `[run.inputs]` |
|
||||
| `[llm]` | `[run.model]` |
|
||||
| `[setup]` | `[run.prepare]` |
|
||||
| `[sandbox]` | `[run.sandbox]` |
|
||||
| `[checkpoint]` | `[run.checkpoint]` |
|
||||
| `[pull_request]` | `[run.pull_request]` |
|
||||
| `[artifacts]` | `[run.artifacts]` |
|
||||
| `[exec]` | `[cli.exec]` |
|
||||
| `[mcp_servers]` | `[run.agent.mcps]` or `[cli.exec.agent.mcps]`, depending on the consumer |
|
||||
| `[api]` | `[server.api]` |
|
||||
| `[web]` | `[server.web]` |
|
||||
| `[artifact_storage]` | `[server.artifacts]` |
|
||||
| Git commit author settings | `[run.git.author]` |
|
||||
| GitHub App and webhook settings | `[server.integrations.github]` |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- The new config language has a small, defensible top-level schema with clear object ownership boundaries.
|
||||
- Users can paste a stanza between `settings.toml`, `fabro.toml`, and `workflow.toml` and still parse successfully.
|
||||
- Same-host and split-host deployments both fit the model without separate schema branches.
|
||||
- Merge behavior is predictable enough that users can explain it from the docs without reading implementation code.
|
||||
- Provider growth in SCM, chat integrations, and object stores does not force repeated top-level redesigns.
|
||||
- Users can disable inherited singleton and keyed-object behavior without a general unset language.
|
||||
- Users can predict flag/env/TOML precedence without reading implementation code.
|
||||
- When users supply old config keys, Fabro fails with targeted upgrade guidance rather than silently ignoring or partially accepting them.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No backwards-compatibility requirements. This is a hard-cut redesign.
|
||||
- No secret-reference syntax such as `${secret.NAME}` in this pass.
|
||||
- No secret backend configuration in this pass.
|
||||
- No structured workflow input schema in this pass.
|
||||
- No prompt-specific run config section in this pass.
|
||||
- No separate validation modes such as “validate as server config” in this pass.
|
||||
- No automatic migration tool in this pass.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Strict top-level namespaces**: Keep the root schema extremely small and reserve underscore-prefixed top-level keys for config-language metadata.
|
||||
- **Same schema everywhere**: File type controls precedence, not which sections are legal.
|
||||
- **Owner-first process config**: CLI and server settings belong to the process that reads them, even in same-host setups.
|
||||
- **Provider-neutral core with provider-specific leaves**: Use this for SCM, notifications, interviews, sandboxes, and object stores where it improves long-term coherence.
|
||||
- **No general unset**: Prefer explicit disable mechanisms such as `enabled = false` and `"none"` selectors.
|
||||
- **Lazy validation for unused stanzas**: This preserves the “paste any stanza anywhere” rule without weakening strict unknown-key validation.
|
||||
- **Shared server listener**: Bind transport and transport TLS are shared at `[server.listen]`; API and web remain separate surfaces above that.
|
||||
- **Separate storage, artifacts, and SlateDB**: These are materially different server concerns and should not be collapsed into one storage section.
|
||||
- **Ordered lists are rare**: Keep them where order is semantically important, especially hooks and prepare steps. Prefer keyed named objects elsewhere.
|
||||
- **Hard-fail migration**: The system should aggressively reject obsolete keys and point users at replacements instead of carrying a compatibility burden into the new language.
|
||||
|
||||
## Canonical Shape
|
||||
|
||||
```toml
|
||||
_version = 1
|
||||
|
||||
[project]
|
||||
[workflow]
|
||||
[run]
|
||||
[cli]
|
||||
[server]
|
||||
[features]
|
||||
```
|
||||
|
||||
Representative subtree:
|
||||
|
||||
```toml
|
||||
_version = 1
|
||||
|
||||
[project]
|
||||
name = "Fabro"
|
||||
description = "AI workflow orchestration"
|
||||
directory = "fabro/"
|
||||
|
||||
[workflow]
|
||||
name = "Implement Feature"
|
||||
description = "Turns a request into a code change"
|
||||
|
||||
[run]
|
||||
goal = "Implement OAuth refresh tokens"
|
||||
working_dir = "/workspace"
|
||||
|
||||
[run.model]
|
||||
provider = "anthropic"
|
||||
name = "sonnet"
|
||||
fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"]
|
||||
|
||||
[run.agent]
|
||||
permissions = "read-write"
|
||||
|
||||
[run.notifications.ops]
|
||||
enabled = true
|
||||
provider = "slack"
|
||||
events = ["run.failed"]
|
||||
|
||||
[run.notifications.ops.slack]
|
||||
channel = "#ops"
|
||||
|
||||
[run.interviews]
|
||||
provider = "slack"
|
||||
|
||||
[run.interviews.slack]
|
||||
channel = "#approvals"
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://fabro.example.com/api/v1"
|
||||
|
||||
[cli.auth]
|
||||
strategy = "mtls"
|
||||
|
||||
[cli.exec.model]
|
||||
provider = "anthropic"
|
||||
name = "claude-opus"
|
||||
|
||||
[cli.exec.agent]
|
||||
permissions = "read-write"
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:32276"
|
||||
|
||||
[server.api]
|
||||
url = "https://fabro.example.com/api/v1"
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "https://fabro.example.com"
|
||||
|
||||
[server.storage]
|
||||
root = "/var/lib/fabro"
|
||||
|
||||
[server.artifacts]
|
||||
provider = "s3"
|
||||
prefix = "artifacts"
|
||||
|
||||
[server.slatedb]
|
||||
provider = "s3"
|
||||
prefix = "runs"
|
||||
flush_interval = "1s"
|
||||
```
|
||||
|
||||
## Canonical File Examples
|
||||
|
||||
Minimal `~/.fabro/settings.toml`:
|
||||
|
||||
```toml
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "unix"
|
||||
path = "~/.fabro/fabro.sock"
|
||||
|
||||
[cli.exec]
|
||||
prevent_idle_sleep = true
|
||||
|
||||
[cli.exec.model]
|
||||
provider = "anthropic"
|
||||
name = "claude-opus"
|
||||
|
||||
[cli.exec.agent]
|
||||
permissions = "read-write"
|
||||
|
||||
[cli.output]
|
||||
format = "text"
|
||||
verbosity = "normal"
|
||||
|
||||
[cli.updates]
|
||||
check = true
|
||||
|
||||
[server.listen]
|
||||
type = "unix"
|
||||
path = "~/.fabro/fabro.sock"
|
||||
|
||||
[server.storage]
|
||||
root = "~/.fabro/storage"
|
||||
|
||||
[run.interviews]
|
||||
provider = "slack"
|
||||
|
||||
[run.interviews.slack]
|
||||
channel = "#approvals"
|
||||
```
|
||||
|
||||
Minimal `fabro.toml`:
|
||||
|
||||
```toml
|
||||
_version = 1
|
||||
|
||||
[project]
|
||||
name = "Fabro"
|
||||
description = "AI workflow orchestration"
|
||||
directory = "fabro/"
|
||||
|
||||
[run.model]
|
||||
provider = "anthropic"
|
||||
name = "sonnet"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "bun install"
|
||||
```
|
||||
|
||||
Minimal `workflow.toml`:
|
||||
|
||||
```toml
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
name = "Implement Feature"
|
||||
description = "Turns a request into a code change"
|
||||
|
||||
[run]
|
||||
goal = "Implement OAuth refresh tokens"
|
||||
|
||||
[run.inputs]
|
||||
repo = "fabro"
|
||||
|
||||
[run.notifications.ops]
|
||||
enabled = true
|
||||
provider = "slack"
|
||||
events = ["run.failed", "run.completed"]
|
||||
|
||||
[run.notifications.ops.slack]
|
||||
channel = "#ops"
|
||||
```
|
||||
|
||||
## Outstanding Questions
|
||||
|
||||
### Deferred to Planning
|
||||
|
||||
- [Affects R64][Technical] What exact run-side SCM targeting fields should live under `[run.scm]` in the first pass: repo slug, owner/repo split, base branch defaults, or additional checkout/ref context?
|
||||
- [Affects R66][Technical] What exact shared field set should the object-store envelope expose before provider-specific subtables begin?
|
||||
- [Affects R90][Technical] What exact field set should the MCP launcher schema expose in addition to `script` xor `command`, `type`, and timeouts?
|
||||
- [Affects R34][Technical] What minimal first-pass notification route surface is required beyond `enabled`, `provider`, and `events`?
|
||||
- [Affects R83][Technical] What duration parser will Fabro standardize on, and what canonical normalization should be shown in error messages and generated examples?
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Update the user-facing config docs to match this new object model.
|
||||
- `/ce:plan` for a migration and implementation plan covering parser changes, merge semantics, docs, and test updates.
|
||||
|
|
@ -92,16 +92,17 @@ These flags set the default model for all nodes that don't have an explicit mode
|
|||
For repeatable runs, set the model in a run config file:
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
goal = "Implement the feature"
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "implement.fabro"
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[run]
|
||||
goal = "Implement the feature"
|
||||
|
||||
[llm.fallbacks]
|
||||
anthropic = ["gemini", "openai"]
|
||||
gemini = ["anthropic", "openai"]
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
fallbacks = ["gemini", "openai"]
|
||||
```
|
||||
|
||||
Then launch with:
|
||||
|
|
@ -110,7 +111,7 @@ Then launch with:
|
|||
fabro run run.toml
|
||||
```
|
||||
|
||||
The `[llm.fallbacks]` table is optional. It maps each provider to an ordered list of fallback providers to try when the primary is unavailable.
|
||||
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable.
|
||||
|
||||
<Note>
|
||||
The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ Fabro can use your project's [devcontainer](https://containers.dev/) configurati
|
|||
|
||||
## Enabling devcontainer support
|
||||
|
||||
Set `devcontainer = true` in the `[sandbox]` section of your run config:
|
||||
Set `devcontainer = true` in the `[run.sandbox]` section of your run config:
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
devcontainer = true
|
||||
```
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ fabro run workflow.fabro --sandbox daytona
|
|||
|
||||
```toml title="run.toml"
|
||||
# Run config TOML
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
```
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ fabro run workflow.fabro --sandbox docker --preserve-sandbox
|
|||
Or in the run config:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "docker"
|
||||
preserve = true
|
||||
```
|
||||
|
|
@ -123,17 +123,17 @@ The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by
|
|||
Snapshots let you pre-build an environment image so each run starts with dependencies already installed. If the named snapshot doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it's ready (up to 10 minutes).
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "rust-dev"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
|
||||
```
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ If the snapshot already exists and is in `Active` state, Fabro uses it directly.
|
|||
Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
project = "fabro"
|
||||
env = "ci"
|
||||
team = "platform"
|
||||
|
|
@ -185,7 +185,7 @@ Fabro prints the sandbox name so you can find it in the [Daytona dashboard](http
|
|||
The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox after a period of inactivity. This saves costs for long-running sandboxes that may sit idle:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 30
|
||||
```
|
||||
|
||||
|
|
@ -215,15 +215,15 @@ Each provider handles outbound network access differently:
|
|||
| `docker` | Bridge network | Set via the `network_mode` config option. Supports all Docker network modes (`bridge`, `none`, `host`, etc.). |
|
||||
| `daytona` | Full access | Configurable via the `network` setting with three modes: `"allow_all"`, `"block"`, or CIDR-based allow lists. |
|
||||
|
||||
For Daytona, network access is configured in the `[sandbox.daytona]` section:
|
||||
For Daytona, network access is configured in the `[run.sandbox.daytona]` section:
|
||||
|
||||
```toml title="run.toml"
|
||||
# Block all egress
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
network = "block"
|
||||
|
||||
# Allow only specific CIDRs
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] }
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -115,16 +115,13 @@ When a handler returns a `Retry` status instead of `Fail`, retries always procee
|
|||
When a model provider fails with a transient error or quota exhaustion, Fabro can automatically switch to a different provider. Configure fallback chains in your [run configuration](/execution/run-configuration):
|
||||
|
||||
```toml title="run.toml"
|
||||
[llm]
|
||||
model = "claude-opus-4-6"
|
||||
[run.model]
|
||||
name = "claude-opus-4-6"
|
||||
provider = "anthropic"
|
||||
|
||||
[llm.fallbacks]
|
||||
anthropic = ["gemini", "openai"]
|
||||
gemini = ["anthropic", "openai"]
|
||||
fallbacks = ["gemini", "openai"]
|
||||
```
|
||||
|
||||
When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference.
|
||||
When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference.
|
||||
|
||||
### What triggers failover
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: "Automatic retrospectives that analyze every workflow run"
|
|||
---
|
||||
|
||||
<Warning>
|
||||
**Experimental feature.** Retros are disabled by default. Enable them with `[features] retros = true` in your project config or server config.
|
||||
**Experimental feature.** Retros are disabled by default. Enable them by setting `retros = true` under `[run.execution]` in your project or workflow config.
|
||||
</Warning>
|
||||
|
||||
After every workflow run, Fabro can generate a **retro** — a structured retrospective that captures what happened, what went well, and what didn't. Retros combine deterministic metrics extracted from the run's checkpoint with a qualitative narrative produced by an LLM agent that analyzes the full event stream.
|
||||
|
|
@ -113,12 +113,12 @@ Both phases run automatically at the end of every CLI run. The API server derive
|
|||
|
||||
### CLI
|
||||
|
||||
To enable retros for your project, set `retros = true` in the `[features]` section of your `fabro.toml`:
|
||||
To enable retros for your project, set `retros = true` under `[run.execution]` in your `fabro.toml`:
|
||||
|
||||
```toml title="fabro.toml"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[features]
|
||||
[run.execution]
|
||||
retros = true
|
||||
```
|
||||
|
||||
|
|
@ -131,7 +131,9 @@ fabro run workflow.fabro --no-retro
|
|||
Retros can also be enabled server-wide in `settings.toml`:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[features]
|
||||
_version = 1
|
||||
|
||||
[run.execution]
|
||||
retros = true
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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, setup commands, variables, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command:
|
||||
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:
|
||||
|
||||
```bash
|
||||
fabro run run.toml
|
||||
|
|
@ -11,142 +11,154 @@ fabro run run.toml
|
|||
|
||||
## Minimal example
|
||||
|
||||
A run config requires two fields:
|
||||
A run config needs at minimum a schema version and a goal:
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[run]
|
||||
goal = "Implement the login feature"
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `version` | Yes | Config format version. Must be `1`. |
|
||||
| `graph` | Yes | Path to the Graphviz workflow file, resolved relative to the TOML file's directory. |
|
||||
| `goal` | No | What the workflow should accomplish. Passed to agents and used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. |
|
||||
| `_version` | No (defaults to `1`) | Schema version. Must be `1` in the first pass. |
|
||||
| `[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 used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. |
|
||||
|
||||
Goal precedence: CLI `--goal` > TOML `goal` > Graphviz graph attribute.
|
||||
Goal precedence: CLI `--goal` > `[run].goal` > Graphviz graph attribute.
|
||||
|
||||
## Full example
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
goal = "Run the CI pipeline for $repo_name"
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "fabro/workflows/ci.fabro"
|
||||
directory = "/tmp/workdir"
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[run]
|
||||
goal = "Run the CI pipeline for $repo_name"
|
||||
working_dir = "/tmp/workdir"
|
||||
|
||||
[llm.fallbacks]
|
||||
anthropic = ["gemini", "openai"]
|
||||
gemini = ["anthropic", "openai"]
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
fallbacks = ["openai", "gemini"]
|
||||
|
||||
[setup]
|
||||
commands = ["git clone $repo_url repo", "cd repo && npm install"]
|
||||
timeout_ms = 120000
|
||||
[[run.prepare.steps]]
|
||||
script = "git clone $repo_url repo"
|
||||
|
||||
[sandbox]
|
||||
[[run.prepare.steps]]
|
||||
script = "cd repo && npm install"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
preserve = false
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
project = "fabro"
|
||||
env = "ci"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "node-20"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
|
||||
|
||||
[sandbox.env]
|
||||
[run.sandbox.env]
|
||||
API_KEY = "${env.MY_API_KEY}"
|
||||
NODE_ENV = "production"
|
||||
|
||||
[checkpoint]
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
repo_name = "fabro"
|
||||
repo_url = "https://github.com/fabro-sh/fabro"
|
||||
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["test-results/**", "playwright-report/**"]
|
||||
|
||||
[mcp_servers.playwright]
|
||||
[run.agent.mcps.playwright]
|
||||
type = "sandbox"
|
||||
command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"]
|
||||
port = 3100
|
||||
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = false
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "pre-check"
|
||||
event = "stage_start"
|
||||
command = "./scripts/pre-check.sh"
|
||||
script = "./scripts/pre-check.sh"
|
||||
blocking = true
|
||||
sandbox = false
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
event = "run_complete"
|
||||
command = "echo done"
|
||||
script = "echo done"
|
||||
```
|
||||
|
||||
## Sections
|
||||
|
||||
### `[llm]`
|
||||
### `[run.model]`
|
||||
|
||||
Override the default model and provider for all nodes that don't have an explicit model assigned via a [stylesheet](/workflows/stylesheets).
|
||||
|
||||
```toml title="run.toml"
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
|
||||
| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
|
||||
| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. |
|
||||
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
|
||||
|
||||
#### `[llm.fallbacks]`
|
||||
#### Fallbacks with splice
|
||||
|
||||
Map each provider to an ordered list of fallback providers. When the primary provider is unavailable, Fabro tries the fallbacks in order:
|
||||
Use the reserved `"..."` marker in `fallbacks` to splice in the inherited list from lower-precedence layers:
|
||||
|
||||
```toml title="run.toml"
|
||||
[llm.fallbacks]
|
||||
anthropic = ["gemini", "openai"]
|
||||
gemini = ["anthropic", "openai"]
|
||||
[run.model]
|
||||
# Prepend "anthropic" to whatever fallbacks the project config already defines.
|
||||
fallbacks = ["anthropic", "..."]
|
||||
```
|
||||
|
||||
### `[setup]`
|
||||
### `[run.prepare]`
|
||||
|
||||
Shell commands to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment.
|
||||
Ordered list of steps to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment.
|
||||
|
||||
```toml title="run.toml"
|
||||
[setup]
|
||||
commands = ["pip install -r requirements.txt", "npm install"]
|
||||
timeout_ms = 60000
|
||||
[[run.prepare.steps]]
|
||||
script = "pip install -r requirements.txt"
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "npm install"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `commands` | List of shell commands, executed sequentially via `sh -c`. |
|
||||
| `timeout_ms` | Per-command timeout in milliseconds. Default: `300000` (5 minutes). |
|
||||
| `script` | Shell-evaluated command (runs through `sh -c`). |
|
||||
| `command` | Argv-style command, mutually exclusive with `script`. |
|
||||
| `env` | Additional environment variables for this step. |
|
||||
|
||||
Each command must exit with status 0. If any command fails or times out, the run aborts before the workflow starts.
|
||||
Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale.
|
||||
|
||||
### `[sandbox]`
|
||||
### `[run.sandbox]`
|
||||
|
||||
Configure how agent tools (bash, file edits) are executed.
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "docker"
|
||||
preserve = true
|
||||
```
|
||||
|
|
@ -157,23 +169,23 @@ preserve = true
|
|||
| `preserve` | When `true`, keep the sandbox alive after the run finishes. Useful for debugging. |
|
||||
| `devcontainer` | When `true`, use the repo's `devcontainer.json` to configure the sandbox. See [Devcontainers](/execution/devcontainers). |
|
||||
|
||||
#### `[sandbox.daytona]`
|
||||
#### `[run.sandbox.daytona]`
|
||||
|
||||
Additional settings when using the Daytona cloud sandbox:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
project = "fabro"
|
||||
env = "staging"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "my-snapshot"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update"
|
||||
# Or reference an external Dockerfile:
|
||||
# dockerfile = { path = "./Dockerfile" }
|
||||
|
|
@ -182,20 +194,20 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update"
|
|||
| Field | Description |
|
||||
|---|---|
|
||||
| `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. |
|
||||
| `labels` | Key-value labels attached to the sandbox for filtering and identification. |
|
||||
| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). |
|
||||
| `snapshot.name` | Snapshot name to create or use for the sandbox. |
|
||||
| `snapshot.cpu` | CPU cores for the snapshot. |
|
||||
| `snapshot.memory` | Memory in GB for the snapshot. |
|
||||
| `snapshot.disk` | Disk in GB for the snapshot. |
|
||||
| `snapshot.cpu` | CPU cores for the snapshot (integer). |
|
||||
| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. |
|
||||
| `snapshot.disk` | Disk size using the same units as `memory`. |
|
||||
| `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. |
|
||||
| `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). |
|
||||
|
||||
#### `[sandbox.local]`
|
||||
#### `[run.sandbox.local]`
|
||||
|
||||
Additional settings when using the local sandbox:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.local]
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "always"
|
||||
```
|
||||
|
||||
|
|
@ -203,29 +215,31 @@ worktree_mode = "always"
|
|||
|---|---|
|
||||
| `worktree_mode` | When to create a git worktree for the run: `always`, `clean` (default — only when the working tree is clean), `dirty` (also when dirty), or `never`. |
|
||||
|
||||
#### `[sandbox.env]`
|
||||
#### `[run.sandbox.env]`
|
||||
|
||||
Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment passthrough using `${env.VARNAME}` syntax:
|
||||
Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment references using `${env.VARNAME}` syntax:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.env]
|
||||
[run.sandbox.env]
|
||||
API_KEY = "${env.MY_API_KEY}"
|
||||
NODE_ENV = "production"
|
||||
SERVICE_URL = "https://api.${env.REGION}.example.com"
|
||||
```
|
||||
|
||||
| Syntax | Description |
|
||||
|---|---|
|
||||
| `"literal"` | Static value passed as-is |
|
||||
| `"${env.VARNAME}"` | Resolved from the host environment at load time. Missing vars produce a hard error. |
|
||||
| `"${env.VARNAME}"` | Whole-value reference resolved from the host environment at consumption time |
|
||||
| `"prefix-${env.X}-suffix"` | Substring interpolation; multiple tokens per string are supported |
|
||||
|
||||
Host env references must be whole-value only — partial interpolation like `"prefix-${env.X}"` is not supported. Sandbox env vars from `settings.toml` defaults and the run config are merged, with the run config winning on key collisions.
|
||||
Missing host variables produce a hard error pointing at the specific field and unresolved token. `run.sandbox.env` is a sticky merge-by-key map: entries from all layers combine, with higher-precedence layers overriding individual keys.
|
||||
|
||||
### `[checkpoint]`
|
||||
### `[run.checkpoint]`
|
||||
|
||||
Configure how git checkpoint commits behave.
|
||||
|
||||
```toml title="run.toml"
|
||||
[checkpoint]
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"]
|
||||
```
|
||||
|
||||
|
|
@ -233,20 +247,20 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"]
|
|||
|---|---|
|
||||
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. |
|
||||
|
||||
Exclude globs from `settings.toml` defaults and the run config are merged (union, deduplicated).
|
||||
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale.
|
||||
|
||||
### `[vars]`
|
||||
### `[run.inputs]`
|
||||
|
||||
Define variables that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference.
|
||||
Define inputs that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference.
|
||||
|
||||
```toml title="run.toml"
|
||||
[vars]
|
||||
[run.inputs]
|
||||
repo_name = "fabro"
|
||||
repo_url = "https://github.com/fabro-sh/fabro"
|
||||
language = "rust"
|
||||
```
|
||||
|
||||
Variables can be used anywhere in the Graphviz file with `$name` syntax:
|
||||
Inputs can be used anywhere in the Graphviz file with `$name` syntax:
|
||||
|
||||
```dot title="c-i.fabro"
|
||||
digraph CI {
|
||||
|
|
@ -256,14 +270,16 @@ digraph CI {
|
|||
}
|
||||
```
|
||||
|
||||
If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is.
|
||||
If a `$variable` in the Graphviz file has no matching entry in `[run.inputs]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is.
|
||||
|
||||
### `[artifacts]`
|
||||
`[run.inputs]` replaces wholesale across layers. Unlike labels, inputs do not merge by key — the highest-precedence layer that sets `inputs` wins its entire map.
|
||||
|
||||
### `[run.artifacts]`
|
||||
|
||||
Configure automatic collection of test artifacts (Playwright reports, JUnit XML, screenshots, etc.) from the execution environment after each stage.
|
||||
|
||||
```toml title="run.toml"
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["test-results/**", "playwright-report/**", "*.trace.zip"]
|
||||
```
|
||||
|
||||
|
|
@ -271,40 +287,41 @@ include = ["test-results/**", "playwright-report/**", "*.trace.zip"]
|
|||
|---|---|
|
||||
| `include` | Glob patterns for files to collect as assets. Matched against the working directory after each stage completes. |
|
||||
|
||||
Artifact collection is opt-in — when no `[artifacts]` section is present, no file scanning occurs. This avoids the overhead of scanning large working directories when assets aren't needed.
|
||||
Artifact collection is opt-in — when no `[run.artifacts]` section is present, no file scanning occurs.
|
||||
|
||||
### `[mcp_servers]`
|
||||
### `[run.agent.mcps]`
|
||||
|
||||
Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table. All three transport types are supported: `stdio`, `http`, and `sandbox`.
|
||||
Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table under `[run.agent.mcps]`. All three transport types are supported: `stdio`, `http`, and `sandbox`.
|
||||
|
||||
```toml title="run.toml"
|
||||
[mcp_servers.playwright]
|
||||
[run.agent.mcps.playwright]
|
||||
type = "sandbox"
|
||||
command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"]
|
||||
port = 3100
|
||||
startup_timeout_secs = 60
|
||||
tool_timeout_secs = 120
|
||||
startup_timeout = "60s"
|
||||
tool_timeout = "2m"
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
|---|---|---|
|
||||
| `type` | Transport type: `"stdio"`, `"http"`, or `"sandbox"`. | — |
|
||||
| `command` | (stdio, sandbox) Array: executable + arguments. | — |
|
||||
| `script` | (stdio, sandbox) Shell-evaluated startup command, mutually exclusive with `command`. | — |
|
||||
| `command` | (stdio, sandbox) Argv array: executable + arguments. | — |
|
||||
| `port` | (sandbox) Port the server listens on inside the sandbox. | — |
|
||||
| `url` | (http) The MCP server endpoint URL. | — |
|
||||
| `env` | (stdio, sandbox) Additional environment variables. | `{}` |
|
||||
| `headers` | (http) Optional HTTP headers for authentication. | `{}` |
|
||||
| `startup_timeout_secs` | Max seconds for server startup + MCP handshake. | `10` |
|
||||
| `tool_timeout_secs` | Max seconds for a single tool call. | `60` |
|
||||
| `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` |
|
||||
| `tool_timeout` | Max duration for a single tool call. | `"60s"` |
|
||||
|
||||
The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details.
|
||||
|
||||
### `[pull_request]`
|
||||
### `[run.pull_request]`
|
||||
|
||||
Automatically open a GitHub pull request when the workflow run completes successfully. Requires a [GitHub App](/integrations/github) to be configured.
|
||||
|
||||
```toml title="run.toml"
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = true
|
||||
auto_merge = false
|
||||
|
|
@ -318,64 +335,46 @@ merge_strategy = "squash"
|
|||
| `auto_merge` | When `true`, enables GitHub auto-merge on the created PR. Implies `draft = false` since GitHub doesn't allow auto-merge on draft PRs. The repository must have auto-merge enabled in GitHub settings. Default: `false`. |
|
||||
| `merge_strategy` | Merge method when `auto_merge` is enabled: `squash` (default), `merge`, or `rebase`. |
|
||||
|
||||
### `[github]`
|
||||
|
||||
Request a scoped GitHub Installation Access Token and inject it into the sandbox as `GITHUB_TOKEN`. The token is minted from the configured [GitHub App](/integrations/github) with only the permissions you specify.
|
||||
|
||||
```toml title="run.toml"
|
||||
[github]
|
||||
permissions = { contents = "write", pull_requests = "read" }
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `permissions` | Map of GitHub API permission names to access levels (`"read"` or `"write"`). Only the listed permissions are requested. |
|
||||
|
||||
This requires a GitHub App to be configured. If the app is missing or the repository doesn't have an installation, the run logs a warning and continues without injecting the token.
|
||||
|
||||
### `[[hooks]]`
|
||||
### `[[run.hooks]]`
|
||||
|
||||
Define hooks that run in response to lifecycle events. Each hook is a TOML array entry:
|
||||
|
||||
```toml title="run.toml"
|
||||
[[hooks]]
|
||||
name = "pre-check"
|
||||
[[run.hooks]]
|
||||
id = "pre-check"
|
||||
name = "Pre-check script"
|
||||
event = "stage_start"
|
||||
command = "./scripts/pre-check.sh"
|
||||
script = "./scripts/pre-check.sh"
|
||||
matcher = "agent_loop"
|
||||
blocking = true
|
||||
timeout_ms = 30000
|
||||
timeout = "30s"
|
||||
sandbox = false
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. |
|
||||
| `name` | Optional display name for the hook. |
|
||||
| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`. |
|
||||
| `command` | Shell command to execute (shorthand for `type = "command"`). |
|
||||
| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. |
|
||||
| `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). |
|
||||
| `command` | Argv-style command (alternative to `script`). |
|
||||
| `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. |
|
||||
| `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. |
|
||||
| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s). |
|
||||
| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. |
|
||||
| `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |
|
||||
|
||||
See [Hooks](/agents/hooks) for hook types beyond simple commands (HTTP, prompt, agent).
|
||||
Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list.
|
||||
|
||||
## Top-level fields
|
||||
|
||||
In addition to the sections above, two optional top-level fields are available:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `directory` | Working directory for the run. Defaults to the current directory. |
|
||||
See [Hooks](/agents/hooks) for hook types beyond scripts (HTTP, prompt, agent).
|
||||
|
||||
## Graph path resolution
|
||||
|
||||
The `graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side:
|
||||
The `[workflow].graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side:
|
||||
|
||||
```
|
||||
project/
|
||||
runs/
|
||||
ci.toml # graph = "ci.fabro"
|
||||
ci.toml # [workflow] graph = "ci.fabro"
|
||||
ci.fabro
|
||||
```
|
||||
|
||||
|
|
@ -388,67 +387,50 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs
|
|||
| Source | Priority |
|
||||
|---|---|
|
||||
| Node-level [stylesheet](/workflows/stylesheets) | Highest |
|
||||
| Run config TOML | |
|
||||
| CLI flags (`--model`, `--provider`, `--sandbox`) | |
|
||||
| Run config TOML (`workflow.toml` or equivalent) | |
|
||||
| Project defaults (`fabro.toml`) | |
|
||||
| Server defaults (`~/.fabro/settings.toml`) | |
|
||||
| Machine defaults (`~/.fabro/settings.toml`) | |
|
||||
| Graphviz graph attributes (`default_model`, `default_provider`) | |
|
||||
| Built-in defaults | Lowest |
|
||||
|
||||
<Note>
|
||||
For model and provider specifically, the precedence is: CLI flags > TOML config > project defaults > server defaults > Graphviz graph attributes > built-in defaults. Stylesheet rules on individual nodes always take priority over all of these.
|
||||
Stylesheet rules on individual nodes always take priority over run config values.
|
||||
</Note>
|
||||
|
||||
### Project defaults (`fabro.toml`)
|
||||
|
||||
The `fabro.toml` project config can set default values for `[llm]`, `[setup]`, `[sandbox]`, `[vars]`, `[checkpoint]`, `[pull_request]`, `[github]`, `[artifacts]`, `[[hooks]]`, and `[mcp_servers]`. These defaults apply to all runs in the project unless the run config overrides them:
|
||||
The `fabro.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:
|
||||
|
||||
```toml title="fabro.toml"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[project]
|
||||
directory = "fabro/"
|
||||
|
||||
[sandbox]
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "my-project-snapshot"
|
||||
|
||||
[github]
|
||||
permissions = { contents = "write" }
|
||||
```
|
||||
|
||||
Project defaults are merged with run config values using the same rules as server defaults — run config wins on key collisions.
|
||||
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), `run.inputs` replaces wholesale, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list.
|
||||
|
||||
### Server defaults
|
||||
### Machine defaults
|
||||
|
||||
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them.
|
||||
|
||||
For variables, defaults and run config are **merged** — the run config wins on key collisions:
|
||||
|
||||
```toml
|
||||
# ~/.fabro/settings.toml
|
||||
[vars]
|
||||
default_key = "from_server"
|
||||
shared = "from_server"
|
||||
|
||||
# run.toml
|
||||
[vars]
|
||||
shared = "from_run" # wins
|
||||
task_key = "from_run"
|
||||
```
|
||||
|
||||
The same merge behavior applies to Daytona labels. All other fields use simple "first non-empty wins" precedence.
|
||||
When running locally, the machine defaults at `~/.fabro/settings.toml` can set run-scoped defaults too. Same merge rules apply.
|
||||
|
||||
## Validation
|
||||
|
||||
Fabro validates the run config when it loads:
|
||||
|
||||
- **Version check** — Only `version = 1` is accepted. Other versions are rejected immediately.
|
||||
- **Required fields** — `version` and `graph` are required. `goal` is optional (can be provided via `--goal` or Graphviz graph attribute).
|
||||
- **Unknown fields** — Extra fields not listed above are silently ignored.
|
||||
- **Variable check** — Any `$variable` in the Graphviz file without a matching `[vars]` entry produces an error.
|
||||
- **`_version` check** — Only `_version = 1` (or missing, which defaults to `1`) is accepted. The legacy top-level `version` key is rejected with a rename hint.
|
||||
- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, `[features]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path.
|
||||
- **Variable check** — Any `$variable` in the Graphviz file without a matching `[run.inputs]` entry produces an error.
|
||||
|
||||
Use `fabro preflight` to validate a run config without executing it:
|
||||
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ Without `--preserve-sandbox`, the SSH session is terminated when the run ends an
|
|||
You can also set `auto_stop_interval` in your run config to control how long an idle sandbox stays alive:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
preserve = true
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -29,30 +29,30 @@ fabro run workflow.fabro --sandbox daytona
|
|||
```
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
```
|
||||
|
||||
A full configuration example with all Daytona-specific options:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
preserve = false
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 60
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
project = "fabro"
|
||||
env = "staging"
|
||||
team = "platform"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "rust-dev"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
|
||||
```
|
||||
|
||||
|
|
@ -64,15 +64,15 @@ Control outbound network access with the `network` field. Three modes are availa
|
|||
|
||||
```toml title="run.toml"
|
||||
# Full access (default)
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
network = "allow_all"
|
||||
|
||||
# Block all egress
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
network = "block"
|
||||
|
||||
# CIDR-based allow list
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] }
|
||||
```
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ fabro run workflow.fabro --sandbox daytona --preserve-sandbox
|
|||
Or in the run config:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
preserve = true
|
||||
```
|
||||
|
|
@ -150,7 +150,7 @@ When preserved, Fabro prints the sandbox name so you can find it in the [Daytona
|
|||
The `auto_stop_interval` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes:
|
||||
|
||||
```toml title="run.toml"
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 30
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic
|
|||
| **OAuth login** | Users sign in to the web UI with their GitHub account |
|
||||
| **Private repo cloning** | Daytona and Docker sandboxes clone private repositories using short-lived Installation Access Tokens |
|
||||
| **Checkpoint pushing** | After each workflow stage, Fabro pushes the run branch and metadata branch back to origin from inside the sandbox |
|
||||
| **Auto-PR** | When `[pull_request] enabled = true` in the [run config](/execution/run-configuration#pull_request), Fabro opens a PR from the agent's working branch after a successful run |
|
||||
| **Auto-merge** | When `[pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass |
|
||||
| **Sandbox GITHUB_TOKEN** | When `[github] permissions` are declared in the run config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox |
|
||||
| **Auto-PR** | When `[run.pull_request] enabled = true` in the [run config](/execution/run-configuration#runpull_request), Fabro opens a PR from the agent's working branch after a successful run |
|
||||
| **Auto-merge** | When `[run.pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass |
|
||||
| **Sandbox GITHUB_TOKEN** | When `[server.integrations.github.permissions]` are declared in the server config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox |
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -61,8 +61,8 @@ The GitHub App check verifies five fields:
|
|||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `git.app_id` | `~/.fabro/settings.toml` |
|
||||
| `git.client_id` | `~/.fabro/settings.toml` |
|
||||
| `server.integrations.github.app_id` | `~/.fabro/settings.toml` |
|
||||
| `server.integrations.github.client_id` | `~/.fabro/settings.toml` |
|
||||
| `GITHUB_APP_CLIENT_SECRET` | Server secret store |
|
||||
| `GITHUB_APP_WEBHOOK_SECRET` | Server secret store |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | Server secret store |
|
||||
|
|
@ -76,8 +76,7 @@ The GitHub App configuration lives in two places:
|
|||
### `~/.fabro/settings.toml`
|
||||
|
||||
```toml title="settings.toml"
|
||||
[git]
|
||||
provider = "github"
|
||||
[server.integrations.github]
|
||||
app_id = "123456"
|
||||
client_id = "Iv1.abc123def"
|
||||
slug = "fabro-a3f2"
|
||||
|
|
@ -85,7 +84,6 @@ slug = "fabro-a3f2"
|
|||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `provider` | Always `"github"` (the only supported provider) |
|
||||
| `app_id` | Numeric GitHub App ID |
|
||||
| `client_id` | OAuth Client ID for the app |
|
||||
| `slug` | App slug, used for linking to the GitHub App settings page |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
# Settings TOML Redesign Implementation Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Use `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` as the source of truth and land this as a hard cut: replace the flat and organic config schema everywhere, update all loaders and consumers to the new namespaced model, and regenerate all outward-facing examples and contracts in the same change.
|
||||
|
||||
Fabro is still greenfield. This plan intentionally optimizes for the best steady-state code rather than backwards compatibility:
|
||||
|
||||
- one user-facing schema, not old and new in parallel
|
||||
- one hard-cut contract update for config files and settings payloads
|
||||
- no user-facing compatibility layer
|
||||
|
||||
This can still land as one cohesive PR. The staged sequence below is an internal implementation order so the work stays mechanically sane while the refactor is in flight.
|
||||
|
||||
This refactor is centered on four seams:
|
||||
|
||||
- schema and parsing in `lib/crates/fabro-types/src/settings/` and `lib/crates/fabro-config/src/config.rs`
|
||||
- layering and trust-boundary resolution in `lib/crates/fabro-config/src/effective_settings.rs`
|
||||
- CLI, workflow, agent, MCP, sandbox, and server consumers across the Rust workspace
|
||||
- public contracts in `docs/api-reference/fabro-api.yaml`, generated clients, generated config files, and `apps/fabro-web`
|
||||
|
||||
## Public Types And Interfaces
|
||||
|
||||
- Replace the flat `fabro_types::Settings` shape with a resolved namespaced settings tree matching the redesign:
|
||||
- `_version`
|
||||
- `project`
|
||||
- `workflow`
|
||||
- `run`
|
||||
- `cli`
|
||||
- `server`
|
||||
- `features`
|
||||
- Replace the current `ConfigLayer` shape with a sparse namespaced parse tree. A temporary in-repo bridge between old and new internal types is acceptable only to keep intermediate stages compiling; it must not become a user-visible compatibility layer and must be deleted by the end of the cut.
|
||||
- Treat `cli.*` and `server.*` as schema-valid everywhere but runtime-consumed only from local `settings.toml` plus explicit process-local overrides.
|
||||
- Replace legacy flat run sections and fields with namespaced equivalents, including:
|
||||
- `goal` and `working_dir` under `[run]`
|
||||
- `vars` to `[run.inputs]`
|
||||
- `labels` to `project.metadata`, `workflow.metadata`, and `run.metadata`
|
||||
- `llm` to `[run.model]`
|
||||
- `setup` to `[run.prepare]`
|
||||
- `mcp_servers` to `[run.agent.mcps.<name>]` or `[cli.exec.agent.mcps.<name>]`, depending on the consumer
|
||||
- `exec` to `[cli.exec]`
|
||||
- flat server sections to `[server.*]`
|
||||
- Treat `vars -> run.inputs` as a behavioral change, not just a rename. `run.inputs` intentionally replaces the inherited map wholesale rather than merging by key.
|
||||
- Replace legacy project shape `[fabro].root` with `[project].directory`.
|
||||
- Replace hook merge identity from effective-name semantics to optional explicit `id`, while keeping `name` human-facing only.
|
||||
- Replace string-command hook and launcher shorthand with one execution-language rule:
|
||||
- `script = "..."` for shell-evaluated commands
|
||||
- `command = ["..."]` for argv launches
|
||||
- mutually exclusive
|
||||
- Treat `script` and `command` fields as trusted executable config. Repo-scoped config using these fields executes with the consuming process privileges. Env interpolation inside `script` is raw string substitution, not shell-escaped templating.
|
||||
- Replace old MCP shapes with agent-scoped MCPs:
|
||||
- `[run.agent.mcps.<name>]`
|
||||
- `[cli.exec.agent.mcps.<name>]`
|
||||
- Keep `SecretStore` and provider ambient auth as the credential sources for secrets. The redesigned config should describe selectors and non-secret knobs, not become a general secret transport.
|
||||
- Keep `/api/v1/settings` as the endpoint path, but replace broad `Settings` serialization with an explicit public DTO. The hard cut is the schema and payload shape, not the path name.
|
||||
|
||||
## Resolved Deferred Questions
|
||||
|
||||
- `run.scm` first pass:
|
||||
- core fields are `provider`, `owner`, and `repository`
|
||||
- provider-specific capability leaves live under `[run.scm.<provider>]`
|
||||
- branch and PR behavior stay out of `run.scm` in this cut and remain on `[run.pull_request]` or runtime context
|
||||
- object-store envelope first pass:
|
||||
- provider-neutral envelope fields are `provider` and optional `prefix`
|
||||
- provider-specific tables live under `[server.artifacts.<provider>]` and `[server.slatedb.<provider>]`
|
||||
- `local` uses `root`, defaulting to `server.storage.root` when omitted
|
||||
- `s3` carries bucket and region plus optional endpoint and path-style settings
|
||||
- provider credentials come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than first-pass secret fields in TOML
|
||||
- MCP surface first pass:
|
||||
- common fields are `enabled`, `type`, `startup_timeout`, and `tool_timeout`
|
||||
- `startup_timeout` and `tool_timeout` use the shared duration type from the value-language helpers
|
||||
- `type = "http"` uses `url` plus optional `headers`
|
||||
- `type = "stdio"` requires exactly one of `script` or `command` and may include `env`
|
||||
- `type = "sandbox"` requires exactly one of `script` or `command`, requires `port` as an integer, and may include `env`
|
||||
- notification route surface first pass:
|
||||
- route envelope fields are `enabled`, `provider`, and `events`
|
||||
- provider-specific destination fields live under `[run.notifications.<name>.<provider>]`
|
||||
- first-pass chat destinations for Slack, Discord, and Teams use `channel`
|
||||
- duration parser first pass:
|
||||
- one shared parser accepts a single unit suffix per value: `ms`, `s`, `m`, `h`, or `d`
|
||||
- composed values like `1h30m` are not supported in first pass; use the smallest needed unit instead
|
||||
- one shared canonical renderer prints human-readable durations in the same single-unit form
|
||||
- size parser first pass:
|
||||
- one shared parser accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`, `GiB`, `TiB`
|
||||
- `KB`, `MB`, `GB`, and `TB` are decimal (powers of 1000); `KiB`, `MiB`, `GiB`, and `TiB` are binary (powers of 1024)
|
||||
- bare values default to `GB`
|
||||
- fractional values are not supported in first pass
|
||||
- one shared canonical renderer prints human-readable sizes using the largest decimal unit that represents the value as an integer multiple
|
||||
- config-language parsing stays permissive; provider layers remain responsible for stricter admissible-value validation such as Daytona-specific CPU and memory limits
|
||||
- object-store `provider` field is a closed enum. First-pass variants are `local` and `s3`. Unknown providers hard-fail against the schema rather than passing through as opaque strings.
|
||||
- `SecretStore` access is not referenced from user TOML in first pass. Consumers read secrets via existing server-side `SecretStore` code paths; the config schema does not introduce a `${secret.NAME}` interpolation form. If TOML-level secret references become necessary later, they are a separate schema bump.
|
||||
|
||||
## Implementation Changes
|
||||
|
||||
### 1. Replace the config parse tree and resolved types
|
||||
|
||||
- Introduce a new namespaced parse tree for `_version`, `project`, `workflow`, `run`, `cli`, `server`, and `features`; do not alias old field names forward.
|
||||
- Redesign `fabro_types::Settings` to match the new resolved schema rather than preserving the old flat representation internally.
|
||||
- Treat strict unknown-key handling as a parse-architecture change, not just a derive tweak. The loader must validate against the full union schema before consumer-specific filtering and must surface targeted rename hints for legacy keys.
|
||||
- Add explicit `_version` handling before deeper validation:
|
||||
- missing defaults to `1`
|
||||
- legacy `version` hard-fails with a rename hint
|
||||
- unsupported higher versions hard-fail with an upgrade hint
|
||||
- Stage the new value-language helpers explicitly instead of bundling them into one opaque parser rewrite:
|
||||
- one shared duration type and parser for config-facing time values
|
||||
- one shared size type and parser for memory and disk values
|
||||
- one model-reference parser for `run.model.fallbacks`
|
||||
- one interpolation representation for `${env.NAME}` tokens, including substring interpolation and multiple tokens per string
|
||||
- one splice-capable string-array helper for the exact `"..."` semantics in the requirements doc
|
||||
- Implement the resolved first-pass shapes from the previous section directly in the parse tree and resolved settings types rather than leaving them to implementer choice.
|
||||
- Redesign run model types to cover:
|
||||
- `run.metadata`
|
||||
- `run.inputs`
|
||||
- `run.model`
|
||||
- `run.git`
|
||||
- `run.prepare.steps`
|
||||
- `run.execution`
|
||||
- `run.checkpoint`
|
||||
- `run.sandbox`
|
||||
- `run.notifications.<name>`
|
||||
- `run.interviews`
|
||||
- `run.agent`
|
||||
- `run.agent.mcps.<name>`
|
||||
- `run.hooks`
|
||||
- `run.scm`
|
||||
- `run.scm.<provider>`
|
||||
- `run.pull_request`
|
||||
- `run.artifacts`
|
||||
- Redesign CLI types to cover:
|
||||
- `cli.target`
|
||||
- `cli.target.tls`
|
||||
- `cli.auth`
|
||||
- `cli.exec`
|
||||
- `cli.exec.model`
|
||||
- `cli.exec.agent`
|
||||
- `cli.exec.agent.mcps.<name>`
|
||||
- `cli.output`
|
||||
- `cli.updates`
|
||||
- `cli.logging`
|
||||
- Redesign server types to cover:
|
||||
- `server.listen`
|
||||
- `server.listen.tls`
|
||||
- `server.api`
|
||||
- `server.web`
|
||||
- `server.auth.api`
|
||||
- `server.auth.web.providers.<provider>`
|
||||
- `server.storage`
|
||||
- `server.artifacts`
|
||||
- `server.slatedb`
|
||||
- `server.scheduler`
|
||||
- `server.logging`
|
||||
- `server.integrations.<provider>`
|
||||
- Keep provider-neutral envelopes and provider-specific nested tables where the requirements already locked them:
|
||||
- sandbox
|
||||
- notifications
|
||||
- interviews
|
||||
- object stores
|
||||
- SCM provider leaves
|
||||
- Keep model config intentionally provider-neutral and implement the fallback grammar exactly as specified in the requirements doc.
|
||||
|
||||
### 2. Narrow merge changes to the paths whose behavior actually changes
|
||||
|
||||
- Keep `Combine` as the default layering mechanism where it still matches the requirements. Add explicit custom merge only for paths whose behavior changes.
|
||||
- Encode the merge matrix from the requirements doc directly in code, with custom logic only for:
|
||||
- replace-by-default maps like `run.inputs`, `project.metadata`, `workflow.metadata`, and `run.metadata`
|
||||
- sticky merge-by-key maps like `run.sandbox.env`
|
||||
- splice-aware string arrays
|
||||
- whole-list replacement for `run.prepare.steps`
|
||||
- field-merge keyed objects like notifications, MCPs, and web-auth providers
|
||||
- ordered hook merging by optional `id`
|
||||
- Make splice-capable arrays explicit in the implementation rather than shape-driven. In the first pass, the only splice-capable array paths are:
|
||||
- `run.model.fallbacks`
|
||||
- `run.notifications.<name>.events`
|
||||
- Treat `"..."` in all non-splice arrays as a hard error rather than data or a silent no-op.
|
||||
- Keep inactive provider and strategy subtables inert when the selected provider changes; validate and consume only the selected subtree.
|
||||
- Move env interpolation out of the current sandbox-only whole-value resolver and into a post-layering resolution pass that runs only on consumed string fields.
|
||||
- If any `${env.NAME}` token in a consumed string fails to resolve, fail the entire field with an error that identifies both the unresolved token and the config path.
|
||||
- Track interpolation provenance so env-sourced resolved values can be redacted consistently in outward-facing serialization, not just in the CLI.
|
||||
- Keep hook ordering stable:
|
||||
- `id`-matched replacement happens in place
|
||||
- anonymous hooks from higher-precedence files append after the fully merged inherited hook list
|
||||
- duplicate `id` values in one file hard-fail
|
||||
|
||||
### 3. Rebuild resolution, trust boundaries, and safe serialization
|
||||
|
||||
- Rework `EffectiveSettingsLayers` and `resolve_settings()` so owner-specific domains are consumed only from `~/.fabro/settings.toml` plus flags and env overrides.
|
||||
- Remove the current “merge everything, then strip server-owned fields” model. Build shared layered domains and owner-specific domains separately from the start.
|
||||
- Preserve today’s `exec` routing behavior:
|
||||
- configured CLI target defaults affect commands that use server targeting
|
||||
- `fabro exec` still requires explicit `--server`
|
||||
- Make the default server auth posture explicit and fail-closed:
|
||||
- if `server.auth` is absent or resolves to no enabled API or web auth configuration, normal server startup must refuse to start
|
||||
- demo and test helpers may continue to inject explicit insecure settings where needed, but insecure startup must be opt-in rather than accidental
|
||||
- Settings API exposure:
|
||||
- replace raw resolved settings serialization with explicit public DTOs
|
||||
- two distinct exposure scopes, each with its own DTO:
|
||||
- scope 1: `/api/v1/settings` (server configuration view)
|
||||
- first-pass allow-list:
|
||||
- `server.api.url`
|
||||
- `server.web.enabled`
|
||||
- `server.web.url`
|
||||
- enabled state for `server.auth.web.providers.*`
|
||||
- non-secret `server.scheduler` values
|
||||
- denies everything else, including all `project.*`, `workflow.*`, `run.*`, `cli.*`, and any `server.*` path not explicitly allowed (notably `server.listen`, `server.listen.tls.*`, `server.auth.api`, `server.integrations.*`, `server.artifacts*`, `server.slatedb*`, local secret-store paths, and any env-resolved secret values)
|
||||
- scope 2: `/api/v1/runs/:id/settings` and run-settings snapshots exposed via API (run configuration view)
|
||||
- allows the resolved `run.*` tree so the frontend run-settings page and equivalent consumers can render it
|
||||
- denies:
|
||||
- any resolved string value tagged as `${env.NAME}`-sourced (via the interpolation provenance tracking)
|
||||
- provider-credential fields under `run.notifications.*.<provider>` even when not env-sourced
|
||||
- env values under `run.agent.mcps.*.env` that were env-interpolated
|
||||
- any field explicitly marked sensitive in its type (for example, tokens or keys)
|
||||
- also denies all `project.*`, `workflow.*`, `cli.*`, and `server.*`; these are not part of a run-configuration view
|
||||
- Apply the matching exposure scope and redaction rules consistently across all outward-facing settings renderers:
|
||||
- `fabro settings` uses the server scope for server-facing rendering and the run scope for run-facing rendering
|
||||
- `/api/v1/settings` uses the server scope
|
||||
- `/api/v1/runs/:id/settings` and any API-exposed run-settings snapshots use the run scope
|
||||
- logs and emitted settings-like debug output use whichever scope matches the payload kind
|
||||
- Trust model:
|
||||
- `script` and `command` fields in repo-scoped config are trusted executable config and should be reviewed like code
|
||||
- those fields execute with the consuming process privileges; the config system does not sandbox them
|
||||
- `${env.NAME}` interpolation inside `script` is raw substitution, not shell quoting or shell-safe templating
|
||||
- Keep command-local override layering separate from machine settings loading:
|
||||
- `run`, `preflight`, and manifest code still build layered run defaults
|
||||
- `exec` still loads machine CLI defaults directly
|
||||
- `settings` still assembles effective layers deliberately
|
||||
- Classify server settings as startup-only vs live-reloadable in the first pass:
|
||||
- live-reloadable:
|
||||
- `server.logging`
|
||||
- `server.scheduler`
|
||||
- startup-only:
|
||||
- `server.listen`
|
||||
- `server.listen.tls`
|
||||
- `server.api`
|
||||
- `server.web`
|
||||
- `server.auth`
|
||||
- `server.storage`
|
||||
- `server.artifacts`
|
||||
- `server.slatedb`
|
||||
- `server.integrations`
|
||||
- Update server runtime application logic to stop assuming old flat fields like `storage_dir`, `artifact_storage`, `api`, and `web`.
|
||||
- Make the persisted-settings decision explicit: old run-settings snapshots and local dev state are not guaranteed to survive the hard cut. Tests, fixtures, and generated examples should be rewritten; no snapshot migration layer is planned.
|
||||
|
||||
### 4. Migrate all consumers, scaffolds, and contracts
|
||||
|
||||
- Update CLI overrides, run manifest building, workflow discovery, project discovery, and remote and local-daemon settings application to the new schema.
|
||||
- Update all crates that currently consume settings or config layers, not just the CLI and server entrypoints. At minimum this includes:
|
||||
- `fabro-cli`
|
||||
- `fabro-server`
|
||||
- `fabro-workflow`
|
||||
- `fabro-agent`
|
||||
- `fabro-mcp`
|
||||
- sandbox-facing config consumers
|
||||
- hook execution consumers
|
||||
- test helpers in `fabro-test`
|
||||
- Update server start and foreground command flows to read and apply the new server config shape.
|
||||
- Update `SecretStore` integration points so server and installer flows continue to source secrets out of band while the new config shape only carries non-secret selectors and toggles.
|
||||
- Update scaffolding and installers so generated `settings.toml`, `fabro.toml`, and `workflow.toml` use `_version` and the new namespaced sections.
|
||||
- Update install-time config writers to stop editing legacy `[git]`, `[web]`, `[api]`, and similar flat sections.
|
||||
- Update the server `/api/v1/settings` response and any run-settings snapshot payloads to the new allow-listed resolved shape, then regenerate Rust and TypeScript clients from OpenAPI.
|
||||
- Update `apps/fabro-web` and any generated TypeScript consumers to the new settings contract. The live `/settings` and `/runs/:id/settings` routes currently `JSON.stringify` the full response, so they remain shape-agnostic, but the static `workflowData` fallback in `apps/fabro-web/app/routes/workflow-detail.tsx` uses the old schema shape and must be rewritten against the new `RunSettings` type.
|
||||
- Update docs and examples in `docs/reference/`, especially:
|
||||
- `user-configuration.mdx`
|
||||
- `cli.mdx`
|
||||
- any other config examples that currently show `[llm]`, `[exec]`, `[server]`, `[sandbox]`, `[fabro]`, or `version = 1`
|
||||
- Update installer, repo-init, and workflow-create generated content so no new files are emitted in the old schema after the cutover lands.
|
||||
|
||||
## Sequencing
|
||||
|
||||
Implement in these internal compile-preserving stages:
|
||||
|
||||
1. Add the new value-language helpers and namespaced sparse parse structs alongside the current code so the repo still builds while parser architecture is being introduced.
|
||||
2. Add the new resolved settings tree plus a temporary internal bridge between old and new types so callers can migrate incrementally without freezing the repo in an unbuildable state.
|
||||
3. Switch parsing and layering to the new schema, strict validation, merge behavior, trust boundaries, and env interpolation. This is where legacy user config starts hard-failing.
|
||||
4. Migrate consumers crate by crate:
|
||||
- `fabro-cli`
|
||||
- `fabro-server`
|
||||
- `fabro-workflow`
|
||||
- `fabro-agent`
|
||||
- `fabro-mcp`
|
||||
- hook, sandbox, and test-helper consumers
|
||||
5. Update `/api/v1/settings`, OpenAPI, generated clients, `apps/fabro-web`, scaffolds, installers, and docs to the new contract.
|
||||
6. Remove the old flat settings types, the temporary bridge, legacy fixtures, and any now-dead merge logic.
|
||||
|
||||
This remains a hard cut. These stages describe implementation order, not a staged user rollout.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Add parser and unit coverage for:
|
||||
- `_version` defaulting and failure modes
|
||||
- representative hard failures for legacy keys and unknown keys
|
||||
- model fallback token parsing and ambiguity errors
|
||||
- duration and size parsing
|
||||
- substring and multi-token `${env.NAME}` interpolation
|
||||
- splice-array rules on allowed paths
|
||||
- hard failure for `"..."` on non-splice paths
|
||||
- hook `id` replacement and anonymous append ordering
|
||||
- Add layering and resolution coverage for:
|
||||
- `run.inputs` replace semantics
|
||||
- `run.sandbox.env` sticky merge semantics
|
||||
- keyed object merge and disable behavior
|
||||
- owner-specific trust boundaries for `cli.*` and `server.*`
|
||||
- inactive provider subtables remaining inert
|
||||
- default server auth fail-closed behavior when `server.auth` is absent
|
||||
- Add serialization and exposure coverage for:
|
||||
- `fabro settings` redaction
|
||||
- `/api/v1/settings` allow-list behavior
|
||||
- exclusion of TLS paths, auth internals, object-store credentials, and env-resolved secrets
|
||||
- any API-exposed run-settings snapshot redaction behavior
|
||||
- Add behavior coverage for:
|
||||
- `project.directory`-based workflow discovery
|
||||
- `run.inputs` replace semantics
|
||||
- hook identity via explicit `id`
|
||||
- Update CLI integration tests in:
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/config.rs`
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/exec.rs`
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs`
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs`
|
||||
- Update server and API coverage for:
|
||||
- `/api/v1/settings`
|
||||
- startup-only vs live-reloadable server settings
|
||||
- run settings snapshots
|
||||
- any tests assuming old flat server settings fields
|
||||
- Update frontend and generated-client expectations after the OpenAPI change.
|
||||
- Update doc examples and snapshot tests that assert generated config files or `fabro settings` output.
|
||||
|
||||
## Assumptions And Defaults
|
||||
|
||||
- Hard cut only: one user-facing schema, no compatibility aliases, and no user-facing compatibility layer.
|
||||
- A temporary internal bridge between old and new settings types is acceptable only to keep intermediate stages compiling and must be removed before the work is done.
|
||||
- `run.inputs` replaces inherited values wholesale; `run.sandbox.env` remains merge-by-key and sticky.
|
||||
- `cli.*` and `server.*` remain schema-valid in all files but are runtime-inert outside local `settings.toml`.
|
||||
- Provider-specific subtables coexist inertly; only the selected provider or strategy subtree is validated and consumed.
|
||||
- Object-store and integration credentials continue to come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than new first-pass secret fields in TOML.
|
||||
- `/api/v1/settings` remains the endpoint path, but its payload shape becomes a new allow-listed public contract.
|
||||
572
docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md
Normal file
572
docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
---
|
||||
date: 2026-04-09
|
||||
status: active
|
||||
topic: settings-toml-redesign
|
||||
predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff.md
|
||||
---
|
||||
|
||||
# Settings TOML Redesign — Handoff 2 (post Stage 6.1–6.5 landing)
|
||||
|
||||
## TL;DR
|
||||
|
||||
Stages 6.1, 6.2, and 6.4 of the Stage 6 follow-up landed cleanly on `main`.
|
||||
Stages 6.3 and 6.5 are **partially** complete — they each hit a concrete
|
||||
blocker that requires Stage 6.6 to be done first. Stage 6.6 (OpenAPI DTO
|
||||
rewrite + fabro-web) is **not started**.
|
||||
|
||||
The workspace builds clean, all 3,756 tests pass, `cargo clippy --workspace
|
||||
-- -D warnings` and `cargo fmt --check --all` are green. There are known
|
||||
runtime behavior changes on the `/api/v1/settings` endpoint (see "Known
|
||||
wire-contract mismatches" below) that will affect fabro-web until 6.6
|
||||
lands.
|
||||
|
||||
The main work that remains is:
|
||||
|
||||
1. **Finish Stage 6.6** — rewrite `docs/api-reference/fabro-api.yaml`,
|
||||
regenerate the Rust progenitor and TypeScript Axios clients, update
|
||||
`fabro-web/app/routes/workflow-detail.tsx`, and rewrite the server's
|
||||
`/api/v1/settings` + `/api/v1/runs/:id/settings` handlers to build
|
||||
allow-list DTOs from the v2 tree without bridging.
|
||||
2. **Unblock Stage 6.3** — 6.6 removes the last reader of the legacy
|
||||
flat `Settings` struct (the progenitor-generated `api::types::ServerSettings`
|
||||
conversion path). Once that's gone, the whole `fabro_types::settings::{hook,
|
||||
mcp, project, run, sandbox, server, user}` module tree plus
|
||||
`fabro_types::combine::Combine` can be deleted.
|
||||
3. **Unblock Stage 6.5** — 6.3's deletion removes the filename collisions
|
||||
that currently prevent flattening `settings/v2/*.rs` up to `settings/*.rs`.
|
||||
4. **Revisit scoped TODOs** — see "Scoped TODOs" below.
|
||||
|
||||
## Source documents
|
||||
|
||||
Read these, in this order:
|
||||
|
||||
1. **Requirements (authoritative)** —
|
||||
[`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md).
|
||||
Source of truth for the v2 schema, merge matrix (R22 / R30 / R71 etc.),
|
||||
trust boundaries, disable semantics. Refer to requirement numbers when
|
||||
making schema decisions.
|
||||
|
||||
2. **Original implementation plan** —
|
||||
[`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md).
|
||||
|
||||
3. **Stage 6 handoff (predecessor to this doc)** —
|
||||
[`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md).
|
||||
This is the doc I worked from. It has the per-stage scope, the file
|
||||
maps, gotchas, and open design questions. **Still current** for the
|
||||
remaining work — read it before touching Stage 6.6.
|
||||
|
||||
## Commit trail (landed on main, most recent first)
|
||||
|
||||
```
|
||||
ace24c410 refactor(types): stage 6.5 promote v2 types to settings top level
|
||||
a3fd3b002 refactor(config): stage 6.4 delete fabro-config re-export shims
|
||||
34a481cd4 refactor(settings): stage 6.3 delete dead Settings helpers + v2 install TOML
|
||||
ea206e0e4 feat(settings): stage 6.2 delete bridge_to_old seam
|
||||
52c295cf7 test(settings): update fabro-cli test suite for v2 settings shape
|
||||
dc856d088 feat(settings): stage 6.1 consumer migration builds workspace-wide
|
||||
5d9aad85a wip(settings): stage 6.1 consumer migration (broken build)
|
||||
842ab71eb feat(types): expose bridge helpers and expand v2 accessors
|
||||
3f32bdb87 feat(types): add SettingsFile convenience accessors
|
||||
```
|
||||
|
||||
Total: 81 files changed, +3,718 / −2,423 lines (net +1,295).
|
||||
|
||||
Note: commit `5d9aad85a` was an explicit broken-build WIP checkpoint
|
||||
the user approved mid-session; `dc856d088` fixes the build. Subsequent
|
||||
commits are individually test-green.
|
||||
|
||||
## Current-state map (what's in the tree now)
|
||||
|
||||
```
|
||||
lib/crates/fabro-types/src/settings/
|
||||
├── mod.rs
|
||||
│ ├── legacy `Settings` struct (flat, _still present_ — see 6.3 status)
|
||||
│ ├── legacy type re-exports from hook/mcp/project/run/sandbox/server/user
|
||||
│ └── NEW: pub use v2::{SettingsFile, InterpString, Duration, ...} ← 6.5
|
||||
│
|
||||
├── hook.rs / mcp.rs / project.rs / run.rs / sandbox.rs / server.rs / user.rs
|
||||
│ └── LEGACY runtime type definitions, still used (see below)
|
||||
│
|
||||
└── v2/
|
||||
├── mod.rs — module root; no more `bridge_to_old` re-export
|
||||
├── tree.rs — SettingsFile top-level
|
||||
├── version.rs
|
||||
├── project.rs / workflow.rs / run.rs / cli.rs / server.rs / features.rs
|
||||
├── duration.rs / size.rs / model_ref.rs / interp.rs / splice_array.rs
|
||||
├── accessors.rs — NEW in 6.1 prep; ~35 flat-view accessors on SettingsFile
|
||||
└── to_runtime.rs — NEW in 6.2; narrow v2→runtime-type helpers
|
||||
(bridge_sandbox, bridge_mcp_entry, bridge_hook,
|
||||
bridge_pull_request, bridge_worktree_mode, etc.)
|
||||
REPLACES the deleted bridge.rs file
|
||||
```
|
||||
|
||||
```
|
||||
lib/crates/fabro-config/src/
|
||||
├── lib.rs — crate root; NEW: top-level `resolve_storage_dir(&SettingsFile)` helper
|
||||
├── config.rs — ConfigLayer newtype; NO MORE `.resolve()` / TryFrom<...> for Settings
|
||||
├── merge.rs — v2 merge matrix, unchanged
|
||||
├── effective_settings.rs — rewritten: returns SettingsFile, v2 merge for server defaults
|
||||
├── project.rs — resolve_working_directory takes &SettingsFile
|
||||
├── run.rs — workflow loaders only (parse_run_config / load_run_config / resolve_graph_path)
|
||||
├── user.rs — machine settings loader + path helpers, no type re-exports
|
||||
├── home.rs / storage.rs / legacy_env.rs — unchanged
|
||||
│
|
||||
└── DELETED in 6.4:
|
||||
hook.rs, mcp.rs, sandbox.rs, server.rs
|
||||
```
|
||||
|
||||
## Stage-by-stage status
|
||||
|
||||
### 6.1 — Migrate consumers off flat `Settings` ✅ **COMPLETE**
|
||||
|
||||
Every production read site in `fabro-workflow`, `fabro-server`,
|
||||
`fabro-cli`, and `fabro-config` reads from `SettingsFile` or walks v2
|
||||
subtrees via `settings::v2::accessors`. `RunRecord.settings`,
|
||||
`RunCreatedProps.settings`, `RunOptions.settings`, `CreateRunInput.settings`,
|
||||
`ValidateInput.settings`, `ResolveWorkflowInput.settings`,
|
||||
`ResolvedWorkflow.settings`, `AppState.settings`, and
|
||||
`CommandContext::machine_settings` are all `SettingsFile`-typed.
|
||||
|
||||
Where the `bridge_to_old`-style conversion to a legacy runtime type was
|
||||
still needed (e.g., `fabro_types::settings::sandbox::SandboxSettings`
|
||||
for `fabro-sandbox`, `fabro_types::settings::mcp::McpServerEntry` for
|
||||
`fabro-mcp`, `fabro_types::settings::hook::HookDefinition` for
|
||||
`fabro-hooks`), the new narrow helpers in
|
||||
`fabro_types::settings::v2::to_runtime` build them from single v2
|
||||
subtrees. Consumers call these explicitly at the point of use.
|
||||
|
||||
### 6.2 — Delete `bridge_to_old` seam ✅ **COMPLETE**
|
||||
|
||||
`lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) is deleted.
|
||||
`ConfigLayer::resolve`, `TryFrom<ConfigLayer> for Settings`, and
|
||||
`TryFrom<&ConfigLayer> for Settings` are deleted. The full-tree
|
||||
conversion from a v2 `SettingsFile` to a legacy flat `Settings` no
|
||||
longer exists anywhere in the codebase.
|
||||
|
||||
The narrow runtime-type helpers that the bridge exported as public
|
||||
functions moved to `fabro_types::settings::v2::to_runtime` and are
|
||||
scoped per runtime type (one helper per runtime struct, not one
|
||||
all-in-one converter). They survive until Stage 6.3 deletes the
|
||||
runtime type targets.
|
||||
|
||||
### 6.3 — Delete legacy flat `Settings` types ⚠️ **PARTIAL (blocked on 6.6)**
|
||||
|
||||
**What landed** (`34a481cd4`):
|
||||
- Every inherent helper method on the legacy `Settings` struct
|
||||
(`app_id`, `slug`, `client_id`, `git_author`, `sandbox_settings`,
|
||||
`setup_settings`, `setup_commands`, `setup_timeout_ms`,
|
||||
`preserve_sandbox_enabled`, `github_permissions`, `mcp_server_entries`,
|
||||
`verbose_enabled`, `prevent_idle_sleep_enabled`, `upgrade_check_enabled`,
|
||||
`dry_run_enabled`, `auto_approve_enabled`, `no_retro_enabled`,
|
||||
`storage_dir`, `slack_settings`) is deleted. Callers migrated to the
|
||||
`SettingsFile` accessors with identical names.
|
||||
- `fabro-cli/src/commands/install.rs::merge_server_settings` now
|
||||
writes v2 TOML (with `[server.{api,listen.tls,web,auth.api.{jwt,mtls},
|
||||
auth.web}]` stanzas). Its tests parse the output through
|
||||
`ConfigLayer::parse` and assert v2 fields.
|
||||
|
||||
**What did NOT land** (blocked on 6.6):
|
||||
- The `Settings` struct itself is **still alive** in
|
||||
`lib/crates/fabro-types/src/settings/mod.rs`.
|
||||
- All seven legacy runtime type modules (`hook.rs`, `mcp.rs`,
|
||||
`project.rs`, `run.rs`, `sandbox.rs`, `server.rs`, `user.rs`) are
|
||||
**still alive** and used by runtime crates.
|
||||
- The `Combine` trait in `lib/crates/fabro-types/src/combine.rs` is
|
||||
**still alive** (only used by the legacy type `#[derive(Combine)]`
|
||||
attributes).
|
||||
- The `fabro-macros` crate's `Combine` derive macro is **still alive**.
|
||||
|
||||
**Why it's blocked on 6.6**: the progenitor-generated OpenAPI client
|
||||
in `lib/crates/fabro-api` deserializes `/api/v1/settings` responses
|
||||
into `api::types::ServerSettings`, which `fabro-cli/src/server_client.rs::
|
||||
retrieve_server_settings()` converts to `fabro_types::Settings` via
|
||||
`convert_type`. That conversion is the only remaining reader of the
|
||||
flat `Settings` shape in production code. Stage 6.6 rewrites the
|
||||
OpenAPI spec so the client returns a v2 DTO and this conversion path
|
||||
goes away.
|
||||
|
||||
**Remaining readers of the legacy `Settings` struct**:
|
||||
| File | Use |
|
||||
|---|---|
|
||||
| `lib/crates/fabro-cli/src/server_client.rs:282` | `retrieve_server_settings` return type |
|
||||
| `lib/crates/fabro-cli/src/commands/config/mod.rs:93` | `legacy_settings_to_v2` shim (takes `&fabro_types::Settings`) |
|
||||
| `lib/crates/fabro-cli/src/commands/install.rs` | gone (tests rewritten) |
|
||||
| `lib/crates/fabro-server/src/demo/mod.rs:1328, 1525` | demo route payloads |
|
||||
| `lib/crates/fabro-server/src/lib.rs:20` | `pub use fabro_types::Settings;` re-export |
|
||||
| `lib/crates/fabro-server/src/web_auth.rs:691` | test (or removed — double-check) |
|
||||
| `lib/crates/fabro-types/src/settings/mod.rs` | definition |
|
||||
|
||||
**Remaining readers of legacy runtime types** (imported via
|
||||
`fabro_types::settings::{hook,mcp,sandbox,server,user,run}`):
|
||||
| Consumer crate | Types it imports |
|
||||
|---|---|
|
||||
| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` |
|
||||
| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, timeouts |
|
||||
| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` |
|
||||
| `fabro-checkpoint` | `GitAuthorSettings` (plus the v2 `GitAuthorLayer` via new `From` impl) |
|
||||
| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` |
|
||||
| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, plus `ServerSettings` for the CLI target |
|
||||
| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` |
|
||||
| `fabro-agent` | `OutputFormat`, `PermissionLevel` (for `AgentArgs`) |
|
||||
|
||||
### 6.4 — Delete `fabro-config` re-export shims ✅ **COMPLETE**
|
||||
|
||||
Files deleted from `lib/crates/fabro-config/src/`:
|
||||
- `hook.rs`, `mcp.rs`, `sandbox.rs`, `server.rs` (pure pass-throughs)
|
||||
|
||||
Files shrunk:
|
||||
- `run.rs` — lost the type re-export block and the dead `resolve_env_refs`
|
||||
helper. Still exports `parse_run_config` / `load_run_config` /
|
||||
`resolve_graph_path` (used by fabro-cli and fabro-server).
|
||||
- `user.rs` — lost the runtime type re-export block. Still exports path
|
||||
helpers, `load_settings_config`, `active_settings_path`, etc.
|
||||
|
||||
`resolve_storage_dir` moved from `fabro-config/src/server.rs` (deleted)
|
||||
to the crate root in `fabro-config/src/lib.rs`. It takes `&SettingsFile`
|
||||
now.
|
||||
|
||||
All ~20 consumer crates updated to import runtime types directly from
|
||||
`fabro_types::settings::{hook,mcp,sandbox,server,user,run}` instead of
|
||||
`fabro_config::{hook,mcp,sandbox,server,user,run}`. The legacy import
|
||||
paths no longer compile.
|
||||
|
||||
### 6.5 — Flatten `settings::v2::*` → `settings::*` ⚠️ **PARTIAL (blocked on 6.3)**
|
||||
|
||||
**What landed** (`ace24c410`):
|
||||
Top-level re-exports of the v2 public surface at `fabro_types::settings`.
|
||||
Consumers can now write:
|
||||
|
||||
```rust
|
||||
use fabro_types::settings::{SettingsFile, InterpString, Duration, ...};
|
||||
```
|
||||
|
||||
Covers `{CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString,
|
||||
ModelRef, ParseDurationError, ParseError, ParseModelRefError,
|
||||
ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, Resolved,
|
||||
ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile,
|
||||
Size, SpliceArray, SpliceArrayError, VersionError, WorkflowLayer,
|
||||
parse_settings_file, validate_version}`.
|
||||
|
||||
**What did NOT land**:
|
||||
Actually moving the v2/*.rs files up to settings/*.rs. This is blocked
|
||||
because the v2 submodule filenames (`project.rs`, `run.rs`, `server.rs`,
|
||||
`cli.rs`) collide with the surviving legacy runtime type files with the
|
||||
same names. Once Stage 6.3 deletes the legacy files, a trivial follow-up
|
||||
commit can:
|
||||
|
||||
1. `git mv lib/crates/fabro-types/src/settings/v2/*.rs lib/crates/fabro-types/src/settings/`
|
||||
2. Delete `lib/crates/fabro-types/src/settings/v2/mod.rs`
|
||||
3. Update `lib/crates/fabro-types/src/settings/mod.rs` to replace
|
||||
`pub mod v2;` + the `pub use v2::{...}` block with direct
|
||||
`pub mod <name>;` declarations and a `pub use ...::*` re-export pass.
|
||||
4. Search-and-replace `::v2::` to nothing across the workspace.
|
||||
5. Update the accessors module and the `to_runtime` module to drop
|
||||
`super::` / `crate::` adjustments.
|
||||
|
||||
### 6.6 — Rewrite OpenAPI contracts and fabro-web DTOs ⏳ **NOT STARTED**
|
||||
|
||||
See the predecessor doc's Stage 6.6 section for the full scope. Key
|
||||
points and anything I've learned since:
|
||||
|
||||
**Files to rewrite**:
|
||||
- `docs/api-reference/fabro-api.yaml`:
|
||||
- Replace the `ServerSettings` schema (~lines 4238–4364 in the
|
||||
untouched version) with an explicit allow-list DTO that maps
|
||||
cleanly onto `SettingsFile`. See the handoff predecessor doc for
|
||||
the field allow-list guidance (R16 / R52 / R53 constraints).
|
||||
- Replace the `RunSettings` schema (~lines 3995–4032) similarly.
|
||||
- Regenerate clients:
|
||||
- Rust progenitor: `cargo build -p fabro-api` (auto-runs `build.rs`).
|
||||
- TypeScript: `cd lib/packages/fabro-api-client && bun run generate`.
|
||||
- `apps/fabro-web/app/routes/workflow-detail.tsx` — rewrite the static
|
||||
`workflowData` literal to match the new DTO.
|
||||
- `lib/crates/fabro-server/src/server.rs::get_server_settings`
|
||||
(around line 1062 — **note: this function was already edited in
|
||||
Stage 6.2** and now serializes the full v2 `SettingsFile` as JSON via
|
||||
`serde_json::to_value(&settings)` with `strip_nulls`. That's a
|
||||
temporary workaround, not the final state — see "Known wire-contract
|
||||
mismatches" below). Stage 6.6 replaces it with explicit allow-list
|
||||
DTO construction from the v2 tree.
|
||||
- `lib/crates/fabro-server/src/server.rs` `/api/v1/runs/:id/settings`
|
||||
handler — still returns `not_implemented` in the real router.
|
||||
- `lib/crates/fabro-server/src/demo/mod.rs` — demo routes still emit
|
||||
legacy Settings shapes. Either migrate to v2 or keep them as the
|
||||
"legacy demo" path.
|
||||
|
||||
**Known wire-contract mismatches** (will affect fabro-web until 6.6 lands):
|
||||
1. **`/api/v1/settings` response shape drift**. The server now emits the
|
||||
v2 `SettingsFile` JSON (e.g., `server.storage.root`,
|
||||
`run.execution.mode`, `cli.output.verbosity`) directly. The OpenAPI
|
||||
spec still declares the legacy `ServerSettings` schema (flat
|
||||
`storage_dir`, `dry_run`, `verbose`). Any client that relies on the
|
||||
spec will see missing fields or mis-typed values. The browser client
|
||||
is the main consumer; fabro-cli's `retrieve_server_settings` still
|
||||
goes through the progenitor client and round-trips through the old
|
||||
JSON shape — it will break on any v2 field the old schema doesn't
|
||||
declare.
|
||||
2. **`openapi_conformance` test**. Still passes because it asserts
|
||||
progenitor types match the YAML — but both sides are now stale
|
||||
relative to what the server actually emits. Stage 6.6 should
|
||||
rewrite this test or update it to cover the new DTOs.
|
||||
|
||||
## Scoped TODOs (stopgap code that needs revisiting)
|
||||
|
||||
Each of these is a deliberate short-term hack with a pointer to where
|
||||
it should land eventually. They're also marked in-line with
|
||||
`// Stage 6.x ...` comments.
|
||||
|
||||
### TODO-1: `legacy_settings_to_v2` shim in fabro-cli
|
||||
**File**: `lib/crates/fabro-cli/src/commands/config/mod.rs:91`
|
||||
**What**: Reverse mapping from `fabro_types::Settings` → `SettingsFile`.
|
||||
Covers `server.storage.root`, `server.scheduler.max_concurrent_runs`,
|
||||
`server.integrations.github.{app_id, client_id, slug}`,
|
||||
`server.integrations.slack.default_channel`, `run.model.{provider, name}`,
|
||||
`run.inputs`, and `cli.output.verbosity`. Does **not** cover most other
|
||||
fields.
|
||||
**Why**: `server_client::retrieve_server_settings` returns the legacy
|
||||
shape because the OpenAPI spec hasn't been rewritten.
|
||||
**Delete when**: Stage 6.6 rewrites the OpenAPI spec and the progenitor
|
||||
client returns v2 natively.
|
||||
|
||||
### TODO-2: `build_legacy_api_settings` in fabro-server
|
||||
**File**: `lib/crates/fabro-server/src/serve.rs:91`
|
||||
**What**: Projects the v2 `server.auth.api.{jwt,mtls}` + `server.listen.tls`
|
||||
subtrees onto the legacy `ApiSettings` struct that the existing
|
||||
`resolve_auth_mode_with_lookup` function still expects.
|
||||
**Why**: The auth resolver in `jwt_auth.rs` hasn't been migrated to v2
|
||||
yet. The v2 structure is different enough (no single `authentication_strategies`
|
||||
enum list; `jwt` and `mtls` are separate subtables with per-strategy
|
||||
`enabled` booleans) that a rewrite is warranted.
|
||||
**Delete when**: Stage 6.6 replaces `resolve_auth_mode_with_lookup` with
|
||||
a v2-aware resolver and deletes the legacy `ApiSettings` type.
|
||||
|
||||
### TODO-3: `get_server_settings` emits raw v2 JSON
|
||||
**File**: `lib/crates/fabro-server/src/server.rs:1063`
|
||||
**What**: The `/api/v1/settings` handler now serializes the full v2
|
||||
`SettingsFile` as JSON with `strip_nulls` instead of building a
|
||||
`ServerSettings` DTO. The spec still declares the old DTO.
|
||||
**Why**: Bridge deletion left no way to produce the old shape without
|
||||
re-introducing `bridge_to_old`.
|
||||
**Fix when**: Stage 6.6 rewrites the OpenAPI spec and builds an explicit
|
||||
allow-list DTO from v2 subtrees. Per R16/R52/R53 in the requirements doc:
|
||||
- **Allow**: `server.api.url`, `server.web.enabled`, `server.web.url`,
|
||||
per-provider enabled state under `server.auth.web.providers.*`,
|
||||
non-secret `server.scheduler` values.
|
||||
- **Deny**: `server.listen.*`, `server.listen.tls.*`, `server.auth.api`,
|
||||
`server.integrations.*`, `server.artifacts*`, `server.slatedb*`, any
|
||||
local `SecretStore` paths, any `InterpString` value whose
|
||||
`Provenance::EnvSourced` is set.
|
||||
|
||||
### TODO-4: `web_auth.rs` register flow
|
||||
**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659`
|
||||
**What**: `setup_register` mutates a v2 TOML document via the new
|
||||
`merge_settings_keys` helper (which now writes v2 top-level stanzas
|
||||
under `[server.{web,auth,integrations.github}]`), writes it to disk,
|
||||
then re-parses it with `ConfigLayer::load` and swaps it into
|
||||
`state.settings`.
|
||||
**Why**: Previously the function wrote legacy v1 TOML (top-level
|
||||
`[web]`/`[api]`/`[git]`) that the v2 parser would reject. It had to be
|
||||
rewritten to stay functional.
|
||||
**Still TODO**: Stage 6.6 should decide whether the register flow
|
||||
belongs in the server at all, or whether the web UI should drive it
|
||||
directly via the HTTP API and a /api/v1/setup endpoint. The current
|
||||
implementation is a hand-rolled TOML writer and loses comments /
|
||||
formatting on round-trip.
|
||||
|
||||
### TODO-5: `check_crypto` in diagnostics walks v2 listen TLS
|
||||
**File**: `lib/crates/fabro-server/src/diagnostics.rs:469-574`
|
||||
**What**: Reads `server.auth.api.{jwt,mtls}.enabled` and
|
||||
`server.listen.tls.{cert,key,ca}` directly from `SettingsFile`.
|
||||
**Why**: Migrated off the bridge. Works, but the error messages
|
||||
reference v2 field paths (e.g., "mTLS configured but
|
||||
[server.listen.tls] is missing"); the `doctor` command hints may need
|
||||
updating for consistency.
|
||||
**Fix when**: Opportunistic, no blocker.
|
||||
|
||||
### TODO-6: Retain-or-delete dead `Combine` trait
|
||||
**Files**:
|
||||
- `lib/crates/fabro-types/src/combine.rs`
|
||||
- `lib/crates/fabro-macros/src/lib.rs` (the `Combine` derive)
|
||||
- Every `#[derive(crate::Combine)]` / `#[derive(Combine)]` on legacy
|
||||
types in `fabro-types/src/settings/{run,sandbox,server,user}.rs` +
|
||||
manual impls in `fabro-types/src/settings/mcp.rs`.
|
||||
**What**: The trait is only used by legacy types for cross-layer
|
||||
merging that v2's `combine_files` function replaced. Nothing external
|
||||
calls `.combine()` on a legacy type.
|
||||
**Delete when**: Stage 6.3 deletes the legacy types. The `Combine`
|
||||
trait, its derive macro, and the `combine.rs` file all go with them.
|
||||
|
||||
### TODO-7: Fallback chain bug preserved
|
||||
**File**: `lib/crates/fabro-workflow/src/operations/start.rs:491-525`
|
||||
**What**: `resolve_fallback_chain` groups all v2 `ModelRef` entries under
|
||||
the empty-string provider key when building the legacy `HashMap<String,
|
||||
Vec<String>>` that `Catalog::build_fallback_chain` expects. Since
|
||||
`build_fallback_chain` looks up by `Provider::as_str()` (e.g.,
|
||||
`"anthropic"`), this **always returns an empty chain**. This preserves
|
||||
the pre-migration behavior exactly.
|
||||
**Fix when**: The model registry work in the requirements doc lands
|
||||
(open question #4 in the predecessor handoff). A proper fix groups
|
||||
fallbacks by actual provider and resolves bare `ModelRef::Bare` tokens
|
||||
against the catalog.
|
||||
|
||||
### TODO-8: V2 doesn't model `goal_file`
|
||||
**File**: `lib/crates/fabro-workflow/src/operations/source.rs:150-160`
|
||||
**What**: V2 has `run.goal` (an `InterpString`) but no separate
|
||||
`run.goal_file`. The legacy CLI `--goal-file` flag can't be expressed
|
||||
in v2. The `resolve_goal_override` helper comments on this.
|
||||
**Fix when**: Either add a `run.goal_file` subfield to the v2 schema
|
||||
(requires a requirements update), or route file-based goals through
|
||||
the workflow-manifest layer the way the server-side flow already does.
|
||||
|
||||
### TODO-9: Server settings inherent methods gone but struct serializes legacy field set
|
||||
**File**: `lib/crates/fabro-types/src/settings/mod.rs:77-146`
|
||||
**What**: The `Settings` struct still has ~30 fields (`llm`, `sandbox`,
|
||||
`setup`, `checkpoint`, `hooks`, `mcp_servers`, `github`, `slack`, `api`,
|
||||
`web`, `features`, `log`, `git`, `fabro`, `storage_dir`, `verbose`,
|
||||
`prevent_idle_sleep`, `upgrade_check`, `dry_run`, `auto_approve`,
|
||||
`no_retro`, `max_concurrent_runs`, `artifact_storage`, `exec`, etc.).
|
||||
These are all dead weight except for the OpenAPI response path and
|
||||
the demo routes.
|
||||
**Delete when**: Stage 6.6 rewrites the OpenAPI spec.
|
||||
|
||||
### TODO-10: Demo routes still emit legacy shape
|
||||
**File**: `lib/crates/fabro-server/src/demo/mod.rs:1327-1560`
|
||||
**What**: Two big `fabro_types::Settings { ... }` literal constructions
|
||||
that feed demo mode responses. The demo path isn't wired into the
|
||||
production API surface (goes through `demo::get_run_settings`).
|
||||
**Fix when**: Either rewrite as v2 `SettingsFile` literals in Stage 6.6,
|
||||
or delete the demo path entirely if it's no longer used by fabro-web.
|
||||
|
||||
### TODO-11: `fabro-cli/tests/it/cmd/config.rs` has an unused `Settings` import
|
||||
**File**: `lib/crates/fabro-cli/tests/it/cmd/config.rs:4`
|
||||
**What**: `use fabro_types::Settings;` is leftover from an earlier
|
||||
migration step. If clippy is happy with it (via re-export?), it's
|
||||
harmless; otherwise remove it.
|
||||
**Check**: `cargo clippy -p fabro-cli --tests -- -D warnings`.
|
||||
|
||||
### TODO-12: Unused `settings_file` binding after `drop(settings)`
|
||||
**File**: `lib/crates/fabro-server/src/web_auth.rs:557-570`
|
||||
**What**: I re-parse the file after writing it and swap into state.
|
||||
The `settings_file` local binding is the pre-edit snapshot; it's no
|
||||
longer used. Double-check the function compiles without a warning and
|
||||
drop the local if it's dead.
|
||||
|
||||
## Scoped open design questions (from the predecessor doc, still open)
|
||||
|
||||
1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?**
|
||||
— It's gone. The natural rename (`into_file(self) -> SettingsFile`)
|
||||
isn't needed because `From<ConfigLayer> for SettingsFile` already
|
||||
exists. Consumers call `.into()`. **Decided: no rename.**
|
||||
|
||||
2. **Post-layering env interpolation resolution pass**. Still not
|
||||
implemented. `InterpString::resolve` is called at read time by each
|
||||
consumer that needs a concrete string. Stage 6.6's allow-list DTO
|
||||
construction will need provenance-aware redaction; the missing pass
|
||||
means each DTO builder has to do its own `.resolve(|name|
|
||||
std::env::var(name).ok())` + provenance check. The requirements doc
|
||||
R79–R81 still specifies a centralized pass under
|
||||
`fabro-config/src/interp_pass.rs`.
|
||||
|
||||
3. **Fail-closed server auth posture**. Still not wired into
|
||||
`fabro-server/src/server.rs` startup. R52/R53 requires that if
|
||||
`server.auth` is absent or resolves to no enabled API / web
|
||||
strategies, normal startup refuses to run, with demo and test
|
||||
helpers opting in explicitly to insecure startup. Stage 6.6 is the
|
||||
natural place — the allow-list DTO construction for
|
||||
`/api/v1/settings` must know the enabled auth strategies, which
|
||||
overlaps with the startup posture check.
|
||||
|
||||
4. **Runtime `ModelRegistry` for `ModelRef::resolve`**. Still unimplemented.
|
||||
`fabro_types::settings::v2::model_ref::ModelRef::resolve` takes a
|
||||
`&dyn ModelRegistry` and errors on ambiguous bare tokens. There's
|
||||
no runtime implementation against `fabro-model::Catalog`. See TODO-7
|
||||
above.
|
||||
|
||||
5. **`run.scm.<provider>` subtree depth**. Still minimal — only
|
||||
`run.scm.github` exists as a placeholder unit struct. Add real
|
||||
fields when the first SCM-specific leaf lands.
|
||||
|
||||
6. **`flatten` + `HashMap` + `deny_unknown_fields`**. Don't try to
|
||||
flatten a HashMap under `deny_unknown_fields`. It doesn't work in
|
||||
serde. Enumerate known providers explicitly (as v2 already does for
|
||||
`NotificationRouteLayer`, `InterviewsLayer`, etc.).
|
||||
|
||||
## Running verification
|
||||
|
||||
```bash
|
||||
# full gate — must stay green after every incremental commit
|
||||
cargo fmt --check --all
|
||||
cargo build --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
ulimit -n 4096 && cargo nextest run --workspace
|
||||
|
||||
# web assets (when touching fabro-web):
|
||||
cd apps/fabro-web && bun run typecheck && bun test && bun run build
|
||||
|
||||
# API spec conformance:
|
||||
cargo nextest run -p fabro-server --test it openapi_conformance
|
||||
```
|
||||
|
||||
Current status on `main`: all of the above are green.
|
||||
|
||||
## Success criteria for finishing Stage 6
|
||||
|
||||
Pulled from the predecessor handoff, updated for what remains:
|
||||
|
||||
- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside
|
||||
the legacy type file that's about to be deleted.
|
||||
**Current: ~9 hits remain — see TODO-1 / TODO-9 / TODO-10.**
|
||||
- [x] `git grep 'bridge_to_old'` returns zero hits.
|
||||
**Done in 6.2.**
|
||||
- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as
|
||||
a subdirectory — its contents are promoted to `settings/*`.
|
||||
**Blocked on 6.3; top-level re-exports landed in 6.5.**
|
||||
- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted.
|
||||
**Blocked on 6.3.**
|
||||
- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs`
|
||||
are either deleted or reduced to thin re-export shells.
|
||||
**hook/mcp/sandbox/server: deleted. run/user: reduced to the
|
||||
helper functions they still own.**
|
||||
- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and
|
||||
`RunSettings` schemas are explicit allow-list DTOs.
|
||||
**Not started (6.6).**
|
||||
- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client
|
||||
are regenerated from the new spec.
|
||||
**Not started (6.6).**
|
||||
- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData`
|
||||
literal matches the new `RunSettings` DTO.
|
||||
**Not started (6.6).**
|
||||
- [x] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` /
|
||||
`cargo nextest run --workspace` / `bun run typecheck` / `bun test`
|
||||
/ `bun run build` gates all stay green.
|
||||
**Rust side: green. Frontend: unverified — the new `/api/v1/settings`
|
||||
JSON shape may break fabro-web at runtime. Verify before merging
|
||||
any frontend release.**
|
||||
|
||||
## Starting points for the next engineer
|
||||
|
||||
1. **Read the predecessor handoff end-to-end** — it has the scope,
|
||||
gotchas, and open design questions.
|
||||
2. **Run the test suite locally** to confirm the starting state
|
||||
(`ulimit -n 4096 && cargo nextest run --workspace`). Expected:
|
||||
3,756 passed / 0 failed / 182 skipped.
|
||||
3. **Verify the wire-contract drift** before touching anything:
|
||||
```bash
|
||||
cargo run -p fabro-cli -- server start # in one terminal
|
||||
curl -s http://localhost:3000/api/v1/settings | jq '.'
|
||||
```
|
||||
You should see the v2 `SettingsFile` shape (`server.storage.root`,
|
||||
`run.execution.mode`, etc.), not the legacy flat shape. This is
|
||||
the state that 6.6 needs to reconcile with the OpenAPI spec.
|
||||
4. **Start 6.6 by drafting the new `ServerSettings` DTO** in the
|
||||
OpenAPI yaml. Use the R16 allow-list from the requirements doc
|
||||
as the starting point. Don't try to be exhaustive — a narrower
|
||||
first cut is easier to review.
|
||||
5. **Generate clients, update `get_server_settings` and
|
||||
`get_run_settings` to build the DTO explicitly**, and only then
|
||||
touch fabro-web. The backend change should be testable in isolation
|
||||
before anything in the frontend moves.
|
||||
6. **After 6.6 lands**, deleting the legacy `Settings` types in 6.3 +
|
||||
flattening the v2 directory in 6.5 becomes mechanical.
|
||||
|
||||
Good luck.
|
||||
364
docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md
Normal file
364
docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
---
|
||||
date: 2026-04-09
|
||||
status: active
|
||||
topic: settings-toml-redesign
|
||||
predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md
|
||||
---
|
||||
|
||||
# Settings TOML Redesign — Handoff 3 (post Stage 6.6 + 6.3b partial)
|
||||
|
||||
## TL;DR
|
||||
|
||||
Stage 6.6 (OpenAPI DTO rewrite + server handlers + CLI migration +
|
||||
fabro-web literals + demo routes) landed cleanly on `main`, and Stage
|
||||
6.3b's first pass — **deleting the legacy flat `fabro_types::Settings`
|
||||
struct itself** — also landed. The legacy flat view is dead code
|
||||
everywhere in production.
|
||||
|
||||
What remains is the *runtime type module cleanup*: the 7 files under
|
||||
`lib/crates/fabro-types/src/settings/{hook,mcp,project,run,sandbox,
|
||||
server,user}.rs` are still alive and consumed by 8 downstream crates.
|
||||
These modules are what blocks Stage 6.5b (flatten `settings/v2/*.rs`
|
||||
up to `settings/*.rs`). The blockers are filename collisions and ~33
|
||||
import statements scattered across the workspace.
|
||||
|
||||
3,758 workspace tests pass. `cargo fmt --check --all` and
|
||||
`cargo clippy --workspace -- -D warnings` are clean. `bun run
|
||||
typecheck`, `bun test`, and `bun run build` for `apps/fabro-web` are
|
||||
green.
|
||||
|
||||
Main work remaining:
|
||||
|
||||
1. **Finish Stage 6.3b** — migrate the 8 consumer crates off the
|
||||
runtime type modules, then delete those 7 files plus the
|
||||
`Combine` trait + derive macro.
|
||||
2. **Stage 6.5b** — trivial once 6.3b finishes: `git mv
|
||||
lib/crates/fabro-types/src/settings/v2/*.rs
|
||||
lib/crates/fabro-types/src/settings/` and sweep `::v2::` out of
|
||||
the workspace.
|
||||
3. **Stage 6.6g** — rewrite `fabro-server` auth resolver for v2
|
||||
(TODO-2 from handoff-2).
|
||||
4. **Stage 6.6j** — review `setup_register` TOML writer in
|
||||
`web_auth.rs` (TODO-4 from handoff-2).
|
||||
5. Remaining scoped TODOs (TODO-5, 7, 8, 11, 12 from handoff-2).
|
||||
|
||||
## Source documents
|
||||
|
||||
Read these, in this order:
|
||||
|
||||
1. **Requirements (authoritative)** —
|
||||
[`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md).
|
||||
2. **Original implementation plan** —
|
||||
[`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md).
|
||||
3. **Stage 6 handoff (predecessor 1)** —
|
||||
[`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md).
|
||||
4. **Stage 6 handoff 2 (immediate predecessor)** —
|
||||
[`docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md`](./2026-04-09-settings-toml-redesign-handoff-2.md).
|
||||
Full per-stage file maps and scoped TODOs; most content still
|
||||
applies.
|
||||
|
||||
## Commit trail (landed on main in this session, most recent first)
|
||||
|
||||
```
|
||||
4a40c73b7 refactor(settings): stage 6.3b delete legacy flat Settings struct
|
||||
65a9fd137 refactor(fabro-web): stage 6.6 rewrite workflowData literal to v2 shape
|
||||
f5b9f82a2 feat(settings): stage 6.6 wire server + CLI to v2 SettingsFile DTO
|
||||
7c8448ece refactor(api): stage 6.6 collapse settings DTOs to freeform v2 shape
|
||||
```
|
||||
|
||||
Net effect: about −3,500 / +500 lines across the four commits.
|
||||
|
||||
## Stage-by-stage status
|
||||
|
||||
### 6.6 — OpenAPI DTO rewrite + fabro-web ✅ **COMPLETE (for the in-scope parts)**
|
||||
|
||||
**What landed** (`7c8448ece` + `f5b9f82a2` + `65a9fd137`):
|
||||
|
||||
- `docs/api-reference/fabro-api.yaml`:
|
||||
- Replaces `ServerSettings` with a `type: object,
|
||||
additionalProperties: true` freeform schema pointing at the v2
|
||||
`SettingsFile` docs.
|
||||
- Replaces `RunSettings` similarly.
|
||||
- Deletes the 20+ orphaned supporting schemas that only those two
|
||||
referenced (`LlmSettings`, `SandboxSettings`, `HookDefinition`,
|
||||
`WebSettings`, `ApiSettings`, `TlsSettings`, `GitSettings`,
|
||||
`AuthSettings`, `Features`, `LogSettings`, `CheckpointSettings`,
|
||||
`PullRequestSettings`, `ArtifactsSettings`, `McpServerEntry`,
|
||||
`GitHubSettings`, `DaytonaSettings`, `LocalSandboxSettings`,
|
||||
`DaytonaSnapshotSettings`, `SetupSettings`, `GitAuthorSettings`,
|
||||
`WebhookSettings`).
|
||||
- Regenerates the Rust progenitor client — `RunSettings` and
|
||||
`ServerSettings` are now `#[serde(transparent)]` newtype wrappers
|
||||
over `serde_json::Map<String, Value>`.
|
||||
- Regenerates the TypeScript Axios client — the orphan
|
||||
`run-settings.ts`, `server-settings.ts`, and 30+ nested model files
|
||||
are deleted; the API methods inline the freeform type
|
||||
as `{ [key: string]: any; }`.
|
||||
- `fabro-server/src/settings_view.rs` (**new module**, ~220 LOC
|
||||
including tests): `redact_for_api(&SettingsFile) -> SettingsFile`
|
||||
drops `server.listen.*`, `server.auth.api.jwt.{issuer,audience}`,
|
||||
`server.auth.api.mtls.ca`, and
|
||||
`server.auth.web.providers.github.client_secret`. 5 unit tests
|
||||
cover each drop case plus a `preserves_run_cli_project_and_features`
|
||||
smoke test.
|
||||
- `fabro-server/src/server.rs::get_server_settings` — now calls
|
||||
`settings_view::redact_for_api` before serializing.
|
||||
- `fabro-server/src/server.rs::get_run_settings` — **new** real
|
||||
handler (was previously `not_implemented`) that opens the run
|
||||
reader, reads the persisted `RunRecord.settings`, redacts, and
|
||||
emits JSON. The demo route still points at `demo::get_run_settings`,
|
||||
which was also rewritten.
|
||||
- `fabro-cli/src/server_client.rs::retrieve_server_settings` — now
|
||||
returns `SettingsFile` directly (not the legacy `Settings`). The
|
||||
body is decoded from the progenitor `types::ServerSettings`
|
||||
transparent newtype via `serde_json::from_value::<SettingsFile>(...)`.
|
||||
- `fabro-cli/src/commands/config/mod.rs::legacy_settings_to_v2` —
|
||||
**deleted** (TODO-1 from handoff-2 resolved). `merged_config`
|
||||
passes the v2 file straight into
|
||||
`effective_settings::resolve_settings`.
|
||||
- `fabro-cli/tests/it/cmd/config.rs` — rewrites
|
||||
`server_settings_fixture` to build a v2 `SettingsFile` via
|
||||
`ConfigLayer::parse` instead of the legacy flat TOML shape.
|
||||
- `fabro-web` — defines local `type ServerSettings =
|
||||
Record<string, unknown>` and `type RunSettings = Record<string,
|
||||
unknown>` aliases in `settings.tsx` / `workflow-api.ts` since the
|
||||
generated client no longer exports named model types. The UI only
|
||||
`JSON.stringify`s these payloads. The static `workflowData`
|
||||
literal in `workflow-detail.tsx` is rewritten to v2 shape
|
||||
(`_version`, `run.goal`, `run.inputs`, `run.model`, `run.sandbox`,
|
||||
`run.prepare.steps`, with `"120s"` / `"8GB"` / `"10GB"` string
|
||||
forms).
|
||||
- `fabro-server/src/demo/mod.rs` — the two demo settings fixtures
|
||||
(`runs::settings()` and `settings::server_settings()`) are
|
||||
rewritten as `serde_json::json!(...)` literals in v2 shape
|
||||
(TODO-10 from handoff-2 resolved).
|
||||
|
||||
**Known remaining wire-contract concerns**:
|
||||
|
||||
1. `openapi_conformance::server_settings_keys_match_openapi_spec`
|
||||
was **deleted** in 6.3b because the new freeform-object schema
|
||||
has no `properties` to diff against. `all_spec_routes_are_routable`
|
||||
remains.
|
||||
2. `bun run dev` / browser sanity check against a real running
|
||||
server is still unverified — the new wire shape should work
|
||||
because fabro-web only stringifies it, but this should be smoke-
|
||||
tested before the next frontend release.
|
||||
|
||||
### 6.6g — Rewrite auth resolver for v2 ⏳ **NOT STARTED**
|
||||
|
||||
TODO-2 from handoff-2 still stands:
|
||||
|
||||
**File**: `lib/crates/fabro-server/src/serve.rs:91` — the
|
||||
`build_legacy_api_settings` stopgap builds a legacy
|
||||
`fabro_types::settings::server::ApiSettings` from the v2
|
||||
`server.auth.api.{jwt,mtls}` + `server.listen.tls` subtrees so that
|
||||
`resolve_auth_mode_with_lookup` in `jwt_auth.rs` still works.
|
||||
|
||||
**Fix**: rewrite `resolve_auth_mode_with_lookup` to read
|
||||
`SettingsFile` directly, delete `build_legacy_api_settings`, and
|
||||
drop the `fabro_types::settings::server::{ApiSettings,
|
||||
ApiAuthStrategy, TlsSettings}` imports from `serve.rs` / `jwt_auth.rs`
|
||||
/ `tls.rs`.
|
||||
|
||||
### 6.6j — setup_register review ⏳ **NOT STARTED**
|
||||
|
||||
TODO-4 from handoff-2 still stands:
|
||||
|
||||
**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659`. The
|
||||
`setup_register` function hand-rolls a v2 TOML document and writes
|
||||
it to disk. It works but loses comments / formatting on round-trip.
|
||||
Plus TODO-12: double-check and drop any dead `settings_file` local
|
||||
binding after the `drop(settings)` write-and-reparse dance at
|
||||
`web_auth.rs:557-570`.
|
||||
|
||||
### 6.3b — Delete legacy flat `Settings` types ⚠️ **PARTIAL**
|
||||
|
||||
**What landed in this session** (`4a40c73b7`):
|
||||
|
||||
- `fabro_types::Settings` struct itself: **deleted** from
|
||||
`lib/crates/fabro-types/src/settings/mod.rs`. All ~65 fields gone.
|
||||
- `fabro_types::Settings` re-export from `fabro_types/src/lib.rs:56`:
|
||||
**deleted**.
|
||||
- `fabro_types::settings::Settings` usage in
|
||||
`fabro-server/src/lib.rs::server_config` module: re-export
|
||||
**deleted**. The `fabro_types::settings::server::*` pass-through
|
||||
is still there because downstream code still imports from it.
|
||||
- `fabro-server/src/demo/mod.rs` — the two demo settings literals
|
||||
(runs::settings + settings::server_settings) were rewritten as
|
||||
v2 `serde_json::json!` literals (6.6i, simultaneously).
|
||||
- `fabro-server/tests/it/openapi_conformance.rs` — deleted the
|
||||
`server_settings_keys_match_openapi_spec` test that built a
|
||||
fully-populated legacy `Settings` to diff against the spec. Kept
|
||||
`all_spec_routes_are_routable`.
|
||||
- `fabro-store/src/run_state.rs` — test fixture switched from
|
||||
`Settings::default()` to `SettingsFile::default()`.
|
||||
- `fabro-types/src/run_event/mod.rs` — two `RunCreated` round-trip
|
||||
tests switched from `Settings::default()` to
|
||||
`SettingsFile::default()`.
|
||||
- `fabro-workflow/tests/it/integration.rs` — the two
|
||||
`hook_toml_*_parsing` tests that decoded top-level `[[hooks]]` into
|
||||
a legacy `Settings` were **deleted**. Those test the legacy parse
|
||||
path which had already been removed in Stage 6.1; the coverage
|
||||
moves to `fabro-types::settings::v2::tree::tests`.
|
||||
|
||||
**What did NOT land** (deferred to Stage 6.3c):
|
||||
|
||||
The 7 runtime type modules under
|
||||
`lib/crates/fabro-types/src/settings/` are still alive:
|
||||
|
||||
- `hook.rs` — `HookDefinition`, `HookEvent`, `HookSettings`,
|
||||
`HookType`, `TlsMode`
|
||||
- `mcp.rs` — `McpServerEntry`, `McpServerSettings`, `McpTransport`,
|
||||
`default_startup_timeout_secs`, `default_tool_timeout_secs`
|
||||
- `project.rs` — `ProjectSettings`
|
||||
- `run.rs` — `ArtifactsSettings`, `CheckpointSettings`, `GitHubSettings`,
|
||||
`LlmSettings`, `MergeStrategy`, `PullRequestSettings`, `SetupSettings`
|
||||
- `sandbox.rs` — `DaytonaNetwork`, `DaytonaSettings`,
|
||||
`DaytonaSnapshotSettings`, `DockerfileSource`, `LocalSandboxSettings`,
|
||||
`SandboxSettings`, `WorktreeMode`
|
||||
- `server.rs` — `ApiAuthStrategy`, `ApiSettings`,
|
||||
`ArtifactStorageBackend`, `ArtifactStorageSettings`, `AuthProvider`,
|
||||
`AuthSettings`, `FeaturesSettings`, `GitAuthorSettings`,
|
||||
`GitProvider`, `GitSettings`, `LogSettings`, `SlackSettings`,
|
||||
`TlsSettings`, `WebSettings`, `WebhookSettings`, `WebhookStrategy`
|
||||
- `user.rs` — `ClientTlsSettings`, `ExecSettings`, `OutputFormat`,
|
||||
`PermissionLevel`, `ServerSettings`
|
||||
|
||||
Plus `fabro-types/src/combine.rs` (the `Combine` trait) and the
|
||||
`fabro-macros` `Combine` derive macro that only these modules use.
|
||||
|
||||
These are blocked on migrating the 8 consumer crates that import
|
||||
them. See "Consumer migration map" below.
|
||||
|
||||
### 6.5b — Flatten `settings::v2::*` → `settings::*` ⏳ **STILL BLOCKED ON 6.3b**
|
||||
|
||||
No change from handoff-2. When 6.3b finishes deleting the runtime
|
||||
type modules, this becomes a trivial `git mv` + search-and-replace
|
||||
pass. The file-name collisions to resolve are `project.rs`, `run.rs`,
|
||||
`server.rs`, `cli.rs` — each exists in both `settings/` and
|
||||
`settings/v2/`.
|
||||
|
||||
## Consumer migration map (for finishing 6.3b)
|
||||
|
||||
| Crate | Legacy types it still imports | Suggested destination |
|
||||
|---|---|---|
|
||||
| `fabro-agent` | `OutputFormat`, `PermissionLevel` from `settings::user` | Promote into `fabro-agent` itself — they're CLI/exec concerns. Or point at `settings::v2::cli::OutputFormat` / `v2::run::AgentPermissions` if shapes match. |
|
||||
| `fabro-checkpoint` | `GitAuthorSettings` from `settings::server` | Promote into `fabro-checkpoint` or read directly from `v2::run::GitAuthorLayer` at the call site. |
|
||||
| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` | Promote all of them into `fabro-hooks`. They are runtime behavior types (has `resolved_hook_type()` / `runs_in_sandbox()` methods), not parse-tree types, so they belong in the consumer crate. |
|
||||
| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, `default_startup_timeout_secs`, `default_tool_timeout_secs` | Promote into `fabro-mcp`. Convert from v2 `run.agent.mcps.*` or `cli.exec.agent.mcps.*` at the call site. |
|
||||
| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` | Already re-exported as `fabro_sandbox::daytona::*` with renames. Promote the source into `fabro-sandbox` directly and drop the re-export path. |
|
||||
| `fabro-checkpoint` | `GitAuthorSettings` | Same as above. |
|
||||
| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` | `MergeStrategy` and `WorktreeMode` have identical v2 equivalents in `v2::run` — point at them directly. `PullRequestSettings` should move into `fabro-workflow`. |
|
||||
| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, `ServerSettings` (as `UserServerSettings`), `GitHubSettings`, `WebSettings`, `AuthSettings`, `GitAuthorSettings`, `WebhookSettings`, `LogSettings`, `FeaturesSettings` | Part of Stage 6.6g — the auth resolver rewrite needs to walk `v2::server::auth` directly; likewise the TLS handling in `tls.rs`. Other types may just need to move into `fabro-server`. |
|
||||
| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` (as `UserServerSettings`) | Promote `ClientTlsSettings` / `ExecSettings` into `fabro-cli`. `OutputFormat` / `PermissionLevel` / `ServerSettings` are shared with `fabro-agent` — decide whether they belong in `fabro-agent` and re-export, or in a new shared crate. |
|
||||
|
||||
**Total import sites to rewrite**: about 33 `use` statements and
|
||||
roughly that many call-sites, across ~15 files in 8 crates. Each
|
||||
individual migration is small; the aggregate is the bulk of the
|
||||
remaining 6.3b work.
|
||||
|
||||
### Combine trait
|
||||
|
||||
After the consumer migration:
|
||||
|
||||
1. `lib/crates/fabro-types/src/combine.rs` — delete.
|
||||
2. `lib/crates/fabro-macros/src/lib.rs::Combine` derive — delete.
|
||||
3. `fabro-macros` crate becomes empty or can go away entirely if
|
||||
there are no other derives in it.
|
||||
|
||||
## Scoped TODOs (handoff-2 status update)
|
||||
|
||||
| TODO | Subject | Status |
|
||||
|---|---|---|
|
||||
| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ **Deleted** in `f5b9f82a2` |
|
||||
| TODO-2 | `build_legacy_api_settings` in fabro-server | ⏳ Still open (6.6g) |
|
||||
| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ **Fixed** in `f5b9f82a2`. Handler now calls `settings_view::redact_for_api` |
|
||||
| TODO-4 | `web_auth.rs` register flow | ⏳ Still open (6.6j) |
|
||||
| TODO-5 | `check_crypto` in diagnostics | ⏳ Opportunistic, unchanged |
|
||||
| TODO-6 | Dead `Combine` trait | ⏳ Still blocked on consumer migration |
|
||||
| TODO-7 | Fallback chain bug preserved | ⏳ Unchanged — waiting on model registry work |
|
||||
| TODO-8 | V2 doesn't model `goal_file` | ⏳ Unchanged — requirements decision needed |
|
||||
| TODO-9 | Server settings inherent methods gone | ✅ **Fixed** — Settings struct is deleted entirely in 6.3b |
|
||||
| TODO-10 | Demo routes still emit legacy shape | ✅ **Fixed** in `4a40c73b7`. Demo fixtures rewritten as v2 JSON |
|
||||
| TODO-11 | Unused `Settings` import in `config.rs` tests | ✅ **Fixed** in `f5b9f82a2`. Test file rewritten to use `SettingsFile` |
|
||||
| TODO-12 | Unused `settings_file` binding in `web_auth.rs` | ⏳ Still open (rolls up into 6.6j) |
|
||||
|
||||
## Running verification
|
||||
|
||||
```bash
|
||||
# Rust side — should stay green after every incremental commit
|
||||
cargo fmt --check --all
|
||||
cargo build --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
ulimit -n 4096 && cargo nextest run --workspace
|
||||
|
||||
# Web side — should stay green when touching fabro-web
|
||||
cd apps/fabro-web && bun run typecheck && bun test && bun run build
|
||||
|
||||
# API spec conformance — single test remaining
|
||||
cargo nextest run -p fabro-server --test it openapi_conformance
|
||||
```
|
||||
|
||||
Expected as of `4a40c73b7`: 3,758 tests pass / 0 fail / 182 skipped.
|
||||
|
||||
## Success criteria for finishing Stage 6
|
||||
|
||||
Updated from handoff-2:
|
||||
|
||||
- [x] `git grep 'fabro_types::Settings\b'` returns zero hits outside
|
||||
the comment in the conformance test.
|
||||
**Done in `4a40c73b7`.**
|
||||
- [x] `git grep 'bridge_to_old'` returns zero hits.
|
||||
- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists
|
||||
as a subdirectory. **Blocked on finishing 6.3b.**
|
||||
- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted.
|
||||
**Blocked on finishing 6.3b.**
|
||||
- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs`
|
||||
deleted or reduced to thin helpers.
|
||||
**Done in Stage 6.4.**
|
||||
- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and
|
||||
`RunSettings` schemas are not the legacy flat shape.
|
||||
**Done in `7c8448ece`** (freeform objects pointing at the v2
|
||||
SettingsFile Rust type).
|
||||
- [x] `lib/packages/fabro-api-client` and the Rust progenitor client
|
||||
are regenerated.
|
||||
**Done in `7c8448ece`.**
|
||||
- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData`
|
||||
literal matches the new shape.
|
||||
**Done in `65a9fd137`.**
|
||||
- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` /
|
||||
`cargo nextest run --workspace` / `bun run typecheck` /
|
||||
`bun test` / `bun run build` gates all green.
|
||||
**Verified after each commit.**
|
||||
|
||||
## Starting points for the next engineer
|
||||
|
||||
1. **Read this doc and handoff-2 in full** — the consumer migration
|
||||
map above is the bulk of the remaining work and rewards careful
|
||||
per-crate thinking.
|
||||
2. **Run the test suite locally** to confirm the starting state
|
||||
(`ulimit -n 4096 && cargo nextest run --workspace`). Expected:
|
||||
3,758 passed / 0 failed / 182 skipped.
|
||||
3. **Pick the smallest consumer first** (suggested order:
|
||||
`fabro-checkpoint` → `fabro-agent` → `fabro-workflow` →
|
||||
`fabro-hooks` → `fabro-mcp` → `fabro-sandbox` → `fabro-cli` →
|
||||
`fabro-server`). For each:
|
||||
a. Move the types into the consumer crate with `git mv` or hand
|
||||
relocation.
|
||||
b. Update the consumer's public API to own them.
|
||||
c. Rewrite the consumer's `From<&SettingsFile>` / construction
|
||||
path to build from v2 subtrees directly.
|
||||
d. Delete the corresponding runtime type file in `fabro-types`.
|
||||
e. Verify `cargo build --workspace`, `cargo clippy --workspace
|
||||
-- -D warnings`, and the relevant nextest subset stay green
|
||||
before moving to the next crate.
|
||||
4. **After the last consumer migrates**, delete `Combine` (trait,
|
||||
derive, crate file).
|
||||
5. **Stage 6.5b** is a one-commit follow-up: `git mv v2/*.rs up`,
|
||||
drop the `::v2::` paths, done.
|
||||
6. **Stage 6.6g and 6.6j** are independent of the above and can be
|
||||
sequenced whenever; 6.6g pairs naturally with the `fabro-server`
|
||||
consumer migration because both touch `jwt_auth.rs` / `serve.rs` /
|
||||
`tls.rs`.
|
||||
|
||||
Good luck.
|
||||
265
docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md
Normal file
265
docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
---
|
||||
date: 2026-04-09
|
||||
status: complete
|
||||
topic: settings-toml-redesign
|
||||
predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md
|
||||
---
|
||||
|
||||
# Settings TOML Redesign — Handoff 4 (Stage 6 complete)
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Stage 6 is done.** Every substage from 6.1 through 6.6j is
|
||||
complete. The legacy flat `Settings` parse tree, its `Combine`-driven
|
||||
layering, the `bridge_to_old` seam, the seven runtime type modules,
|
||||
and the transitional `v2/` subdirectory are all deleted. The
|
||||
`fabro_types::settings` module is now flat and v2-native.
|
||||
|
||||
3,758 workspace tests pass. `cargo fmt --check --all`,
|
||||
`cargo clippy --workspace -- -D warnings`, and
|
||||
`cd apps/fabro-web && bun run typecheck && bun test && bun run build`
|
||||
are all green.
|
||||
|
||||
There is no remaining Stage 6 work to hand off. Any follow-ups from
|
||||
here are *new* decisions (see "Deferred / new work" below).
|
||||
|
||||
## What landed in this wrap-up session
|
||||
|
||||
Fifteen commits on `main` on top of handoff-3's starting point:
|
||||
|
||||
```
|
||||
c625747e0 refactor(settings): stage 6.5b sweep ::v2:: prefix out of consumers
|
||||
d82d167f0 refactor(settings): stage 6.6g rewrite auth resolver for v2
|
||||
15b799fb3 refactor(settings): stage 6.3b + 6.5b finish — delete last legacy server types and flatten v2/
|
||||
3ac7ab903 refactor(settings): stage 6.3b shrink server runtime types + delete Combine
|
||||
7f9640aac refactor(settings): stage 6.3b promote run runtime types + delete to_runtime
|
||||
6df8bbeb3 refactor(settings): stage 6.3b promote sandbox runtime types into fabro-sandbox
|
||||
38dacb874 refactor(settings): stage 6.3b promote mcp runtime types into fabro-mcp
|
||||
2016c8e94 refactor(settings): stage 6.3b promote hook + project runtime types
|
||||
db45511ff refactor(settings): stage 6.3b promote user runtime types into consumers
|
||||
```
|
||||
|
||||
Each commit is small, test-green, and self-contained. The migration
|
||||
walked one consumer crate at a time through the consumer migration
|
||||
map from handoff-3.
|
||||
|
||||
### Stage 6.3b complete — runtime type module tree deletion
|
||||
|
||||
Every consumer that used to import from
|
||||
`fabro_types::settings::{hook, mcp, project, run, sandbox, server,
|
||||
user}` now owns its runtime types locally:
|
||||
|
||||
| Old module | Runtime types moved to |
|
||||
|---|---|
|
||||
| `hook.rs` | `fabro-hooks/src/config.rs` |
|
||||
| `mcp.rs` | `fabro-mcp/src/config.rs` |
|
||||
| `sandbox.rs` | `fabro-sandbox/src/config.rs` |
|
||||
| `run.rs` → `PullRequestSettings`, `MergeStrategy`, `ArtifactsSettings` | `fabro-workflow/src/config.rs` |
|
||||
| `user.rs` → `OutputFormat`, `PermissionLevel` | `fabro-agent/src/cli.rs` |
|
||||
| `user.rs` → `ClientTlsSettings` | `fabro-cli/src/user_config.rs` |
|
||||
| `server.rs` → `ApiAuthStrategy`, `ApiSettings`, `TlsSettings` | `fabro-server/src/jwt_auth.rs` (temporarily; see 6.6g) |
|
||||
| `project.rs` | deleted (`ProjectSettings` was dead) |
|
||||
|
||||
Dead types deleted outright (no consumers remained):
|
||||
|
||||
- From `run.rs`: `LlmSettings`, `SetupSettings`, `CheckpointSettings`,
|
||||
`GitHubSettings`.
|
||||
- From `user.rs`: `ExecSettings`, legacy `ServerSettings`.
|
||||
- From `server.rs`: `AuthProvider`, `AuthSettings`, `GitProvider`,
|
||||
`GitSettings`, `GitAuthorSettings`, `WebSettings`, `WebhookSettings`,
|
||||
`WebhookStrategy`, `SlackSettings`, `FeaturesSettings`,
|
||||
`LogSettings`, `ArtifactStorageBackend`, `ArtifactStorageSettings`.
|
||||
|
||||
Narrow v2→runtime bridge helpers that used to live in
|
||||
`fabro-types::settings::v2::to_runtime` moved alongside their target
|
||||
types:
|
||||
|
||||
- `bridge_hook` → `fabro_hooks::config::bridge_hook`
|
||||
- `bridge_mcp_entry` / `bridge_mcps` → `fabro_mcp::config::*`
|
||||
- `bridge_sandbox` / `bridge_worktree_mode` → `fabro_sandbox::config::*`
|
||||
- `bridge_pull_request` / `bridge_merge_strategy` / `bridge_run_artifacts`
|
||||
→ `fabro_workflow::config::*`
|
||||
|
||||
`fabro-types/src/settings/v2/to_runtime.rs` is deleted.
|
||||
|
||||
### `Combine` trait machinery deleted
|
||||
|
||||
- `lib/crates/fabro-types/src/combine.rs` — deleted.
|
||||
- `pub mod combine;` / `pub use fabro_macros::Combine;` removed from
|
||||
`fabro-types/src/lib.rs`.
|
||||
- `#[proc_macro_derive(Combine)]` and its `syn::{Data, DeriveInput,
|
||||
Fields}` imports removed from `fabro-macros/src/lib.rs`. The
|
||||
`e2e_test` attribute macro is untouched.
|
||||
|
||||
### Stage 6.5b complete — v2 directory flatten
|
||||
|
||||
- `git mv lib/crates/fabro-types/src/settings/v2/*.rs
|
||||
lib/crates/fabro-types/src/settings/`
|
||||
- `lib/crates/fabro-types/src/settings/v2/` — deleted.
|
||||
- `settings/mod.rs` absorbs the old `v2/mod.rs` declarations and
|
||||
re-exports (accessors, cli, duration, features, interp, model_ref,
|
||||
project, run, server, size, splice_array, tree, version, workflow).
|
||||
- A final workspace sweep rewrote every
|
||||
`fabro_types::settings::v2::*` import path to
|
||||
`fabro_types::settings::*` — 53 files, 10 crates.
|
||||
- The transitional `pub mod v2 { pub use super::*; }` alias is also
|
||||
deleted; there is no `::v2::` namespace anywhere.
|
||||
|
||||
### Stage 6.6g complete — auth resolver v2-native
|
||||
|
||||
- `resolve_auth_mode_with_lookup` rewritten to take `&SettingsFile`
|
||||
directly and walk
|
||||
`settings.server.auth.api.{jwt,mtls}` +
|
||||
`settings.server.auth.web.allowed_usernames` +
|
||||
`settings.server.listen.tls`.
|
||||
- Strategy presence uses the "subtree present unless `enabled = false`"
|
||||
semantics from R52.
|
||||
- The `ApiSettings` and `ApiAuthStrategy` shim types and the
|
||||
`build_legacy_api_settings` helper in `serve.rs` are **deleted**
|
||||
(~60 LOC).
|
||||
- `TlsSettings` survives as a local helper in
|
||||
`fabro-server/src/jwt_auth.rs` with a
|
||||
`TlsSettings::from_settings(&SettingsFile)` constructor that
|
||||
projects `server.listen.tls` into the resolved triple. It's only
|
||||
used by `tls.rs`'s rustls builder and the mTLS integration test.
|
||||
- `serve.rs`'s bootstrap now calls the new resolver directly.
|
||||
|
||||
## Final status of every Stage 6 substage
|
||||
|
||||
| Substage | Status |
|
||||
|---|---|
|
||||
| 6.1 — Migrate consumers off flat `Settings` | ✅ COMPLETE (predecessor session) |
|
||||
| 6.2 — Delete `bridge_to_old` seam | ✅ COMPLETE (predecessor session) |
|
||||
| 6.3 — Delete legacy flat `Settings` helpers | ✅ COMPLETE (predecessor session) |
|
||||
| 6.3b — Delete `Settings` struct + 7 runtime type modules | ✅ **COMPLETE** |
|
||||
| 6.4 — Delete `fabro-config` re-export shims | ✅ COMPLETE (predecessor session) |
|
||||
| 6.5 — Promote v2 types to top-level `settings::*` re-exports | ✅ COMPLETE (predecessor session) |
|
||||
| 6.5b — Flatten `settings/v2/*.rs` → `settings/*.rs` | ✅ **COMPLETE** |
|
||||
| 6.6a/b — Design allow-list DTOs in OpenAPI | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6c — Regenerate Rust + TS clients | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6d — Rewrite `get_server_settings` with redaction | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6e — Rewrite `get_run_settings` handler | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6f — Migrate `retrieve_server_settings` in fabro-cli | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6g — Rewrite auth resolver for v2 | ✅ **COMPLETE** |
|
||||
| 6.6h — Update fabro-web `workflow-detail.tsx` DTO literal | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6i — Migrate demo routes to v2 | ✅ COMPLETE (this session, prior) |
|
||||
| 6.6j — Rewrite `setup_register` web_auth flow | ✅ **COMPLETE** (was already v2-writing after predecessor session; TODO-12's dead `settings_file` binding turned out to not exist anymore) |
|
||||
|
||||
## Scoped TODO status (from handoff-2)
|
||||
|
||||
| TODO | Subject | Final status |
|
||||
|---|---|---|
|
||||
| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ Deleted |
|
||||
| TODO-2 | `build_legacy_api_settings` in fabro-server | ✅ Deleted (6.6g) |
|
||||
| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ Replaced with redacted DTO |
|
||||
| TODO-4 | `web_auth.rs` register flow rewrite | ⚠️ **Partial** — the hand-rolled TOML writer now emits v2 shape and is tested. Comment/formatting preservation on round-trip is a nice-to-have left for a follow-up pass; see "Deferred / new work" |
|
||||
| TODO-5 | `check_crypto` in diagnostics | ✅ Walks v2 listen TLS (predecessor session) |
|
||||
| TODO-6 | Dead `Combine` trait | ✅ Deleted |
|
||||
| TODO-7 | Fallback chain bug preserved | ⚠️ **Unchanged** — still preserves pre-migration behavior; needs the runtime `ModelRegistry` implementation |
|
||||
| TODO-8 | V2 doesn't model `goal_file` | ⚠️ **Unchanged** — needs requirements-level decision |
|
||||
| TODO-9 | Legacy Settings struct dead weight | ✅ Deleted (6.3b) |
|
||||
| TODO-10 | Demo routes still emit legacy shape | ✅ Rewritten as v2 JSON |
|
||||
| TODO-11 | Unused `Settings` import in config tests | ✅ Removed |
|
||||
| TODO-12 | Unused `settings_file` binding in web_auth.rs | ✅ No such binding exists (already cleaned up) |
|
||||
|
||||
## Deferred / new work
|
||||
|
||||
These are *not* Stage 6 items. They are open questions or new
|
||||
improvements that came up during the work and are worth considering
|
||||
separately.
|
||||
|
||||
1. **`setup_register` comment-preserving TOML writes (ex-TODO-4).**
|
||||
The current hand-rolled writer uses `toml` + `toml::to_string_pretty`
|
||||
which loses comments and formatting on round-trip. A fix would use
|
||||
`toml_edit::DocumentMut` (new workspace dependency). Alternatively,
|
||||
the whole GitHub App registration flow might be better driven from
|
||||
fabro-web as a dedicated `/api/v1/setup` endpoint instead of living
|
||||
in `setup_register`.
|
||||
|
||||
2. **Runtime `ModelRegistry` for `ModelRef::resolve` (ex-TODO-7).**
|
||||
`fabro_types::settings::model_ref::ModelRef::resolve` still takes
|
||||
a `&dyn ModelRegistry` and errors on ambiguous bare tokens. There's
|
||||
no runtime implementation against `fabro-model::Catalog`, so the
|
||||
`resolve_fallback_chain` helper in
|
||||
`fabro-workflow/src/operations/start.rs` still groups all fallbacks
|
||||
under the empty-string provider key and never matches. This
|
||||
preserves pre-migration behavior exactly but isn't the correct
|
||||
fallback behavior. Open question from predecessor handoff #4.
|
||||
|
||||
3. **`run.goal_file` schema support (ex-TODO-8).** V2 has `run.goal`
|
||||
as an `InterpString` but no separate `run.goal_file`. The legacy
|
||||
CLI `--goal-file` flag can't be expressed in v2. Either add a
|
||||
`run.goal_file` subfield (requirements update) or route file-based
|
||||
goals through the workflow-manifest layer.
|
||||
|
||||
4. **Fail-closed server auth posture (open question #3).** The
|
||||
requirements doc R52/R53 specifies that startup should refuse to
|
||||
run if `server.auth` is absent or resolves to no enabled API/web
|
||||
strategies, with demo and test helpers opting in explicitly. The
|
||||
current `resolve_auth_mode_with_lookup` just logs a warning and
|
||||
builds an `AuthMode::Strategies(empty)`. A follow-up can tighten
|
||||
this — the hook point is already clean now that 6.6g landed.
|
||||
|
||||
5. **Post-layering env interpolation resolution pass (open question
|
||||
#2).** `InterpString::resolve` is still called at read time by
|
||||
each consumer that needs a concrete string. The requirements doc
|
||||
R79–R81 specifies a centralized pass under
|
||||
`fabro-config/src/interp_pass.rs` that runs once after layering.
|
||||
Not implemented in any handoff so far.
|
||||
|
||||
6. **OpenAPI freeform settings DTO vs formal allow-list DTO.** Stage
|
||||
6.6a/b chose to declare `ServerSettings` and `RunSettings` as
|
||||
`type: object, additionalProperties: true` freeform objects in the
|
||||
OpenAPI spec, pointing at the Rust `SettingsFile` type for the
|
||||
shape. This loses client-side type safety in TypeScript (the
|
||||
generated client returns `{ [key: string]: any }`). A follow-up
|
||||
could formalize the full v2 `SettingsFile` tree in OpenAPI yaml
|
||||
(tedious but not hard), or keep the loose shape and provide a
|
||||
hand-written TypeScript type declaration in
|
||||
`@qltysh/fabro-api-client` as a convenience.
|
||||
|
||||
7. **`TlsSettings` in `fabro-server/src/jwt_auth.rs`.** This 3-field
|
||||
struct is the last legacy-shaped leftover. It's technically owned
|
||||
by the right crate now, but putting it in `jwt_auth.rs` is a
|
||||
historical artifact — a dedicated `fabro-server/src/tls_config.rs`
|
||||
module would be a more natural home. Pure cleanup, no urgency.
|
||||
|
||||
## Running verification
|
||||
|
||||
```bash
|
||||
cargo fmt --check --all
|
||||
cargo build --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
ulimit -n 4096 && cargo nextest run --workspace
|
||||
|
||||
cd apps/fabro-web && bun run typecheck && bun test && bun run build
|
||||
```
|
||||
|
||||
All green as of `c625747e0` on `main`: 3,758 tests passed / 0 failed
|
||||
/ 182 skipped.
|
||||
|
||||
## Success criteria for Stage 6 (all resolved)
|
||||
|
||||
- [x] `git grep 'fabro_types::Settings\b'` returns zero hits.
|
||||
- [x] `git grep 'bridge_to_old'` returns zero hits.
|
||||
- [x] `lib/crates/fabro-types/src/settings/v2/` no longer exists as
|
||||
a subdirectory.
|
||||
- [x] `lib/crates/fabro-types/src/combine.rs` is deleted.
|
||||
- [x] `lib/crates/fabro-types/src/settings/{hook,mcp,project,run,
|
||||
sandbox,user}.rs` legacy runtime modules — deleted.
|
||||
`settings/server.rs` now exists as the *v2* server layer file
|
||||
(promoted from `v2/server.rs` in 6.5b).
|
||||
- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs`
|
||||
deleted or reduced to helpers.
|
||||
- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and
|
||||
`RunSettings` schemas are not the legacy flat shape.
|
||||
- [x] `lib/packages/fabro-api-client` and the Rust progenitor client
|
||||
are regenerated.
|
||||
- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData`
|
||||
literal matches the new shape.
|
||||
- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` /
|
||||
`cargo nextest run --workspace` / `bun run typecheck` /
|
||||
`bun test` / `bun run build` gates all green.
|
||||
|
||||
Stage 6 is closed. Next work should be driven by the deferred items
|
||||
list above or by new requirements.
|
||||
608
docs/plans/2026-04-09-settings-toml-redesign-handoff.md
Normal file
608
docs/plans/2026-04-09-settings-toml-redesign-handoff.md
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
---
|
||||
date: 2026-04-09
|
||||
status: active
|
||||
topic: settings-toml-redesign
|
||||
predecessor: docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md
|
||||
---
|
||||
|
||||
# Settings TOML Redesign — Handoff to Stage 6 Follow-up
|
||||
|
||||
## TL;DR
|
||||
|
||||
Stages 1–5 of the settings TOML redesign landed on `main` across 13 commits. The
|
||||
user-facing hard cut is complete: every Fabro config file now parses against
|
||||
the v2 namespaced schema, legacy top-level keys hard-fail with targeted rename
|
||||
hints, the merge matrix is implemented per the normative requirements doc,
|
||||
trust boundaries work across all three resolution modes, all scaffolds and
|
||||
docs are migrated, and the workspace is 100% tests-green (**3,760 passed / 0
|
||||
failed**), clippy-clean, and correctly formatted.
|
||||
|
||||
The remaining work is **Stage 6: delete the legacy flat `Settings` shape and
|
||||
the transitional `bridge_to_old` seam**, plus the OpenAPI + generated clients
|
||||
+ fabro-web DTO rewrite that was explicitly deferred from Stage 5. This
|
||||
document is everything you need to continue the work in a fresh session.
|
||||
|
||||
## Source documents
|
||||
|
||||
Read these before starting, in order:
|
||||
|
||||
1. **Requirements (authoritative)** —
|
||||
[`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](./../brainstorms/2026-04-08-settings-toml-redesign-requirements.md).
|
||||
This is the source of truth for the v2 schema, merge matrix, trust
|
||||
boundaries, and disable semantics. Refer to requirement numbers (R1–R90)
|
||||
when you change schema rules so decisions stay traceable.
|
||||
|
||||
2. **Original implementation plan** —
|
||||
[`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md).
|
||||
This is the 6-stage sequence and the scope of what needs to land. Stage 6
|
||||
in that document is the list of things this handoff still owes.
|
||||
|
||||
3. **Representative canonical example** — the `representative_full_tree_parses`
|
||||
test in
|
||||
[`lib/crates/fabro-types/src/settings/v2/tree.rs`](../../lib/crates/fabro-types/src/settings/v2/tree.rs#L295-L422).
|
||||
If you want a feel for how the whole v2 schema fits together, read this
|
||||
fixture before anything else.
|
||||
|
||||
## Current-state map
|
||||
|
||||
### What the tree looks like at handoff
|
||||
|
||||
```
|
||||
lib/crates/fabro-types/src/settings/
|
||||
├── mod.rs — transitional seam; hosts legacy flat Settings +
|
||||
│ module comment explaining the deletion plan
|
||||
├── v2/ — authoritative v2 schema (Stages 1–2 output)
|
||||
│ ├── mod.rs — module root; re-exports
|
||||
│ ├── tree.rs — SettingsFile top-level; parse_settings_file(),
|
||||
│ │ ParseError with rename-hint table
|
||||
│ ├── version.rs — _version pre-validation
|
||||
│ ├── project.rs — ProjectLayer
|
||||
│ ├── workflow.rs — WorkflowLayer
|
||||
│ ├── run.rs — RunLayer + all run subtree types (536 LOC)
|
||||
│ ├── cli.rs — CliLayer + cli subtree types
|
||||
│ ├── server.rs — ServerLayer + server subtree types
|
||||
│ ├── features.rs — FeaturesLayer
|
||||
│ ├── duration.rs — Duration value-language helper
|
||||
│ ├── size.rs — Size value-language helper
|
||||
│ ├── model_ref.rs — ModelRef + ambiguity resolution
|
||||
│ ├── interp.rs — InterpString with provenance tagging
|
||||
│ ├── splice_array.rs — SpliceArray "..." marker
|
||||
│ └── bridge.rs — TRANSITIONAL: bridge_to_old(&SettingsFile)->Settings
|
||||
│ (~820 LOC — this is the thing Stage 6 deletes)
|
||||
├── hook.rs, mcp.rs, project.rs, run.rs, sandbox.rs, server.rs, user.rs
|
||||
│ — LEGACY flat type definitions. Delete in Stage 6.
|
||||
└── (combine trait is in ../combine.rs — also legacy, also deletes)
|
||||
|
||||
lib/crates/fabro-config/
|
||||
├── lib.rs — module tree (note: combine.rs + settings.rs
|
||||
│ deleted in Stage 6 initial cleanup)
|
||||
├── config.rs — ConfigLayer newtype over SettingsFile; exposes
|
||||
│ ::parse/::load/::combine/::resolve/::as_v2
|
||||
├── merge.rs — v2 merge matrix implementation (683 LOC,
|
||||
│ covers every row of the normative table)
|
||||
├── effective_settings.rs — EffectiveSettingsLayers + resolve_settings
|
||||
│ with LocalOnly/RemoteServer/LocalDaemon modes
|
||||
│ and trust-boundary stripping
|
||||
├── project.rs — workflow discovery + resolve_fabro_root
|
||||
├── user.rs — load_settings_config + legacy file warnings
|
||||
├── run.rs — parse_run_config + resolve_env_refs helper +
|
||||
│ re-export shim of resolved run types
|
||||
├── sandbox.rs, server.rs, — THIN re-export shims. Stage 6 deletes these
|
||||
│ hook.rs, mcp.rs once consumers stop importing through them.
|
||||
├── storage.rs — unrelated; stays
|
||||
├── home.rs — 1-line Home re-export
|
||||
└── legacy_env.rs — 12-line legacy env var helper
|
||||
```
|
||||
|
||||
### Dependency chain to understand
|
||||
|
||||
```
|
||||
TOML file
|
||||
│
|
||||
▼ parse_settings_file() (fabro-types/src/settings/v2/tree.rs)
|
||||
SettingsFile (v2)
|
||||
│
|
||||
▼ combine_files() (fabro-config/src/merge.rs)
|
||||
SettingsFile (v2, merged)
|
||||
│
|
||||
▼ bridge_to_old() (fabro-types/src/settings/v2/bridge.rs)
|
||||
Settings (legacy flat)
|
||||
│
|
||||
▼ every consumer that reads (~84 call sites across 15 files)
|
||||
settings.llm, settings.vars, settings.sandbox, ...
|
||||
```
|
||||
|
||||
The **bridge is the only producer of the legacy flat `Settings` shape**.
|
||||
Removing it requires every reader to consume `SettingsFile` directly.
|
||||
|
||||
## Commit log (Stages 1–5 landed on `main`)
|
||||
|
||||
```
|
||||
c6d515be4 fix(lint): clean up fabro-config test clippy warnings
|
||||
31db613aa docs(config): point new code at ConfigLayer::as_v2 rather than the bridge
|
||||
3dd3c7bf8 refactor(config): delete unused legacy shim modules, document transitional seam
|
||||
dba10e5e9 docs: migrate reference and guide examples to v2 config shape
|
||||
b57248236 test(migration): land final Stage 4 fixes — 100% workspace tests green
|
||||
2fc85282b fix(effective_settings): keep cli/server stanzas from user settings.toml
|
||||
a6047250c fix(lint): clippy cleanup for Stage 3/4 consumer migration
|
||||
f4a79b896 test(cli): migrate remaining config/exec/create fixtures to v2
|
||||
f467bd23c fix(bridge): use hook command shorthand to avoid duplicate serde key
|
||||
eabbca649 feat(tests): migrate fabro-cli fixtures and repo fabro.toml to v2
|
||||
a0eec6aee feat(config): switch parser and layering to v2 schema
|
||||
bb228643e feat(types): flesh out v2 subtrees and add legacy bridge
|
||||
288e73321 feat(types): add settings v2 parse tree scaffolding
|
||||
```
|
||||
|
||||
Total: 76 files changed, +6,413 / -2,151 lines.
|
||||
|
||||
## Stage 6 work breakdown
|
||||
|
||||
Stage 6 has **six independent subtasks**. Each subtask can land as its own PR
|
||||
on top of `main` — they have a natural dependency order but can be paused
|
||||
between steps because the transitional bridge keeps the workspace building at
|
||||
every intermediate state.
|
||||
|
||||
### 6.1 — Migrate consumer read sites from flat `Settings` to v2 `SettingsFile`
|
||||
|
||||
**Scope**: ~84 field-access sites across 15 files (grep below).
|
||||
|
||||
**Files to touch** (ordered easy → hard):
|
||||
|
||||
```
|
||||
lib/crates/fabro-workflow/src/run_options.rs — 5 sites, mostly behind accessor methods
|
||||
lib/crates/fabro-workflow/src/operations/source.rs — 3 sites
|
||||
lib/crates/fabro-workflow/src/operations/create.rs — ~8 sites, touches LLM mutation
|
||||
lib/crates/fabro-workflow/src/operations/start.rs — ~10 sites, touches setup/hooks/llm
|
||||
lib/crates/fabro-cli/src/commands/run/runner.rs — a few sites
|
||||
lib/crates/fabro-cli/src/commands/exec.rs — a few sites
|
||||
lib/crates/fabro-cli/src/manifest_builder.rs — 2 sites (goal, goal_file)
|
||||
lib/crates/fabro-server/src/run_manifest.rs — ~11 sites in handlers + tests
|
||||
lib/crates/fabro-server/src/server.rs — ~14 sites (biggest file)
|
||||
lib/crates/fabro-server/src/web_auth.rs — ~20 sites (git settings heavy)
|
||||
lib/crates/fabro-server/src/serve.rs — a few sites
|
||||
lib/crates/fabro-config/src/effective_settings.rs — apply_server_defaults copies every field
|
||||
lib/crates/fabro-config/src/project.rs — resolve_working_directory reads settings.work_dir
|
||||
lib/crates/fabro-cli/tests/it/cmd/create.rs — 7 sites in assertions
|
||||
lib/crates/fabro-cli/tests/it/cmd/runner.rs — 4 sites in assertions
|
||||
```
|
||||
|
||||
Exact grep:
|
||||
|
||||
```bash
|
||||
grep -rn 'settings\.llm\|settings\.vars\|settings\.sandbox\|settings\.setup\|settings\.hooks\|settings\.checkpoint\|settings\.pull_request\|settings\.mcp_servers\|settings\.artifacts\|settings\.git\|settings\.exec\|settings\.fabro\|settings\.goal\|settings\.work_dir\|settings\.labels\|settings\.github' lib/crates --include='*.rs'
|
||||
```
|
||||
|
||||
**Migration pattern** (before → after):
|
||||
|
||||
```rust
|
||||
// BEFORE (legacy flat)
|
||||
let model = settings.llm.as_ref().and_then(|llm| llm.model.clone());
|
||||
let provider = settings.llm.as_ref().and_then(|llm| llm.provider.clone());
|
||||
```
|
||||
|
||||
```rust
|
||||
// AFTER (v2 via ConfigLayer::as_v2())
|
||||
let model = layer
|
||||
.as_v2()
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|r| r.model.as_ref())
|
||||
.and_then(|m| m.name.as_ref())
|
||||
.map(InterpString::as_source);
|
||||
```
|
||||
|
||||
**Recommended sequence**:
|
||||
|
||||
1. **Start with receive-side accessor methods** on `ConfigLayer` and
|
||||
`RunOptions`. For every flat field that consumers read, add an accessor
|
||||
method that walks the v2 tree. Land these additively (no caller changes
|
||||
yet). Example:
|
||||
```rust
|
||||
impl ConfigLayer {
|
||||
pub fn run_model_name(&self) -> Option<String> {
|
||||
self.file.run.as_ref()
|
||||
.and_then(|r| r.model.as_ref())
|
||||
.and_then(|m| m.name.as_ref())
|
||||
.map(InterpString::as_source)
|
||||
}
|
||||
}
|
||||
```
|
||||
2. **Migrate one caller at a time**, file-by-file, smallest first. After each
|
||||
file: `cargo build -p <crate>` + `cargo nextest run -p <crate>` before
|
||||
moving on. Do not try to cover 15 files at once — incremental commits.
|
||||
3. **Delete the flat-field helper methods on `Settings`** as nothing reads
|
||||
them. They are in
|
||||
[`lib/crates/fabro-types/src/settings/mod.rs`](../../lib/crates/fabro-types/src/settings/mod.rs#L111-L196):
|
||||
`app_id()`, `slug()`, `client_id()`, `git_author()`, `sandbox_settings()`,
|
||||
`setup_settings()`, `setup_commands()`, `setup_timeout_ms()`,
|
||||
`preserve_sandbox_enabled()`, `github_permissions()`,
|
||||
`mcp_server_entries()`, `verbose_enabled()`, `prevent_idle_sleep_enabled()`,
|
||||
`upgrade_check_enabled()`, `dry_run_enabled()`, `auto_approve_enabled()`,
|
||||
`no_retro_enabled()`, `storage_dir()`, `slack_settings()`. These will
|
||||
cascade compiler errors into callers that you can then migrate.
|
||||
|
||||
**Gotchas**:
|
||||
|
||||
- **`settings.vars` vs v2 `run.inputs`**: v2 replaces wholesale (R22). If a
|
||||
consumer was relying on `vars` merging across layers, its behavior was
|
||||
ambiguous before and is now explicit — it sees whichever layer set `inputs`
|
||||
last. Check tests after migration.
|
||||
- **`settings.setup.commands` vs v2 `run.prepare.steps`**: v2 replaces the
|
||||
whole ordered list (R30). Several tests were re-asserted in Stage 4;
|
||||
similar audits will be needed for any newly-migrated code path.
|
||||
- **`settings.work_dir`** is the bridge output of `run.working_dir`
|
||||
(`InterpString`). When consumers want the raw string, call
|
||||
`InterpString::as_source()`. When they want an env-resolved value, call
|
||||
`InterpString::resolve(|name| std::env::var(name).ok())` — the v2
|
||||
interpolation pass is not yet wired into the default resolve path.
|
||||
- **`settings.github.permissions`** maps to
|
||||
`server.integrations.github.permissions` in v2, which means it lives in
|
||||
the owner-specific domain and is stripped from fabro.toml / workflow.toml
|
||||
layers per R16. Consumers in `fabro-workflow` that read it will need to
|
||||
either lift the read to a call site that has access to the server-local
|
||||
layer, or accept that workflow-level config cannot ask for GitHub token
|
||||
permissions. Flag this as an open design question if you hit it.
|
||||
|
||||
### 6.2 — Delete the `bridge_to_old` seam
|
||||
|
||||
**Files**:
|
||||
- `lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) — delete
|
||||
entirely.
|
||||
- `lib/crates/fabro-types/src/settings/v2/mod.rs` — drop the
|
||||
`pub mod bridge;` and `pub use bridge::bridge_to_old;` lines.
|
||||
- `lib/crates/fabro-config/src/config.rs` — delete the
|
||||
`TryFrom<ConfigLayer> for Settings` and `TryFrom<&ConfigLayer> for Settings`
|
||||
impls, the `bridge_to_old` import, and change `ConfigLayer::resolve(self) ->
|
||||
Settings` to `ConfigLayer::into_file(self) -> SettingsFile` (or just
|
||||
encourage `From<ConfigLayer> for SettingsFile` which already exists).
|
||||
|
||||
**Prerequisite**: 6.1 must be complete — there must be zero readers of flat
|
||||
`Settings` left. `git grep 'fabro_types::Settings\b'` should return nothing
|
||||
outside of the legacy type definitions themselves.
|
||||
|
||||
**Known consumer of `bridge_to_old`**: only `ConfigLayer::resolve` in
|
||||
[`lib/crates/fabro-config/src/config.rs`](../../lib/crates/fabro-config/src/config.rs#L133-L140).
|
||||
No external callers. This is the last thing to unwire before the bridge can
|
||||
be deleted.
|
||||
|
||||
### 6.3 — Delete the legacy flat types
|
||||
|
||||
**Files to delete** (and remove from `mod.rs` re-export lists):
|
||||
|
||||
```
|
||||
lib/crates/fabro-types/src/settings/mod.rs — Settings struct, impls, tests
|
||||
lib/crates/fabro-types/src/settings/hook.rs — HookDefinition, HookEvent, HookType, HookSettings, TlsMode
|
||||
lib/crates/fabro-types/src/settings/mcp.rs — McpServerEntry, McpServerSettings, McpTransport
|
||||
lib/crates/fabro-types/src/settings/project.rs — ProjectSettings
|
||||
lib/crates/fabro-types/src/settings/run.rs — LlmSettings, SetupSettings, CheckpointSettings,
|
||||
PullRequestSettings, ArtifactsSettings,
|
||||
GitHubSettings, MergeStrategy
|
||||
lib/crates/fabro-types/src/settings/sandbox.rs — SandboxSettings, DaytonaSettings, DaytonaSnapshotSettings,
|
||||
LocalSandboxSettings, DaytonaNetwork, WorktreeMode,
|
||||
DockerfileSource
|
||||
lib/crates/fabro-types/src/settings/server.rs — ApiSettings, WebSettings, GitSettings, GitAuthorSettings,
|
||||
AuthSettings, AuthProvider, ApiAuthStrategy, TlsSettings,
|
||||
WebhookSettings, WebhookStrategy, GitProvider,
|
||||
FeaturesSettings, LogSettings, SlackSettings,
|
||||
ArtifactStorageSettings, ArtifactStorageBackend
|
||||
lib/crates/fabro-types/src/settings/user.rs — ClientTlsSettings, ExecSettings, OutputFormat,
|
||||
PermissionLevel, ServerSettings
|
||||
lib/crates/fabro-types/src/combine.rs — Combine trait (unused after deletes above)
|
||||
lib/crates/fabro-macros/src/lib.rs — keep `#[derive(Combine)]` if any non-legacy use;
|
||||
otherwise delete the derive macro entry
|
||||
```
|
||||
|
||||
**Prerequisite**: 6.2 must be complete (bridge deleted).
|
||||
|
||||
**Dependency chain**: `Combine` is used _only_ by legacy flat type derives
|
||||
today. Search with
|
||||
```bash
|
||||
grep -rn '#\[derive(.*Combine\|impl Combine\|fabro_types::combine\|fabro_types::Combine' lib/crates --include='*.rs'
|
||||
```
|
||||
If the only hits are inside `fabro-types/src/settings/*.rs` legacy files, the
|
||||
trait + derive are safe to delete in the same PR.
|
||||
|
||||
### 6.4 — Delete the `fabro-config` re-export shims
|
||||
|
||||
**Files** (all are 1–62 LOC thin pass-throughs):
|
||||
|
||||
```
|
||||
lib/crates/fabro-config/src/hook.rs — re-exports fabro_types::settings::hook::*
|
||||
lib/crates/fabro-config/src/mcp.rs — re-exports fabro_types::settings::mcp::*
|
||||
lib/crates/fabro-config/src/sandbox.rs — re-exports fabro_types::settings::sandbox::*
|
||||
lib/crates/fabro-config/src/server.rs — re-exports fabro_types::settings::server::* + resolve_storage_dir()
|
||||
lib/crates/fabro-config/src/user.rs — re-exports fabro_types::settings::user::* + path helpers
|
||||
lib/crates/fabro-config/src/run.rs — re-exports fabro_types::settings::run::* +
|
||||
parse_run_config + resolve_env_refs + resolve_graph_path
|
||||
```
|
||||
|
||||
**Before deleting**, migrate callers off them. The callers are listed in the
|
||||
file-level commit `3dd3c7bf8` — summary: `fabro-hooks`, `fabro-mcp`,
|
||||
`fabro-sandbox`, `fabro-agent`, `fabro-cli`, `fabro-server`,
|
||||
`fabro-workflow`, plus a handful of test files import via
|
||||
`fabro_config::<module>::...` paths. Each should import directly from
|
||||
`fabro_types::settings::v2::...` once the legacy types are gone.
|
||||
|
||||
**Retain**:
|
||||
- `fabro-config/src/run.rs` **`resolve_graph_path()`** — still used, not
|
||||
legacy. Move it to `fabro-config/src/project.rs` or `fabro-config/src/lib.rs`.
|
||||
- `fabro-config/src/run.rs` **`parse_run_config()`** — still used by
|
||||
`fabro-server/src/run_manifest.rs` and `fabro-cli/src/manifest_builder.rs`.
|
||||
It's already a thin `ConfigLayer::parse` wrapper. Either keep it as a
|
||||
top-level function in `fabro-config/src/lib.rs` or inline at call sites.
|
||||
- `fabro-config/src/run.rs` **`resolve_env_refs()`** — the legacy minimal env
|
||||
resolver. Once consumers use `InterpString::resolve` directly, delete.
|
||||
- `fabro-config/src/user.rs` **path helpers** (`default_settings_path`,
|
||||
`default_socket_path`, `active_settings_path`, legacy path helpers,
|
||||
`load_settings_config`) — still used by CLI commands. Move them to
|
||||
`fabro-config/src/lib.rs` or a new `fabro-config/src/paths.rs`.
|
||||
|
||||
### 6.5 — Flatten `settings::v2::*` → `settings::*`
|
||||
|
||||
Once Stages 6.3 + 6.4 are done and `fabro-types/src/settings/` only contains
|
||||
the old `v2/` directory plus a mostly-empty `mod.rs`, rename everything to
|
||||
be the primary namespace:
|
||||
|
||||
```
|
||||
fabro-types/src/settings/
|
||||
├── mod.rs (re-exports direct from subdirs, no more v2 prefix)
|
||||
├── tree.rs
|
||||
├── version.rs
|
||||
├── project.rs
|
||||
├── workflow.rs
|
||||
├── run.rs
|
||||
├── cli.rs
|
||||
├── server.rs
|
||||
├── features.rs
|
||||
├── duration.rs
|
||||
├── size.rs
|
||||
├── model_ref.rs
|
||||
├── interp.rs
|
||||
└── splice_array.rs
|
||||
```
|
||||
|
||||
Rewrite imports across the workspace — `use fabro_types::settings::v2::...`
|
||||
becomes `use fabro_types::settings::...`.
|
||||
|
||||
**Recommendation**: one big mechanical commit with just the rename; do not
|
||||
mix with behavior changes.
|
||||
|
||||
### 6.6 — Rewrite OpenAPI contracts + regenerate clients + fix fabro-web
|
||||
|
||||
This is the piece that was explicitly deferred from Stage 5 because the
|
||||
current bridge-backed `/api/v1/settings` response still works against the
|
||||
existing `ServerSettings` schema. Owning the explicit allow-list DTOs is the
|
||||
end-state the plan calls for (requirements doc "Validation Boundary" +
|
||||
implementation plan Stage 5).
|
||||
|
||||
**Files to rewrite**:
|
||||
|
||||
- `docs/api-reference/fabro-api.yaml` — replace the current flat
|
||||
`ServerSettings` schema (lines ~4238–4364) and `RunSettings` schema
|
||||
(lines ~3995–4032) with explicit allow-list DTOs. The allow-lists are
|
||||
spelled out in the implementation plan under "Rebuild resolution, trust
|
||||
boundaries, and safe serialization":
|
||||
- **`/api/v1/settings` (server scope)**: allow only `server.api.url`,
|
||||
`server.web.enabled`, `server.web.url`, per-provider enabled state for
|
||||
`server.auth.web.providers.*`, and non-secret `server.scheduler` values.
|
||||
Deny everything else — notably `server.listen.*`, `server.listen.tls.*`,
|
||||
`server.auth.api`, `server.integrations.*`, `server.artifacts*`,
|
||||
`server.slatedb*`, any local SecretStore paths, and any env-resolved
|
||||
values tagged via `InterpString` provenance.
|
||||
- **`/api/v1/runs/{id}/settings` (run scope)**: allow the resolved `run.*`
|
||||
tree. Deny: any `InterpString` value whose resolution provenance shows
|
||||
it was sourced from `${env.NAME}`, provider-credential fields under
|
||||
`run.notifications.*.<provider>`, env values under
|
||||
`run.agent.mcps.*.env` that were env-interpolated, and any field
|
||||
explicitly marked sensitive. Deny all `project.*`, `workflow.*`,
|
||||
`cli.*`, and `server.*` — they're not part of a run view.
|
||||
- **Then regenerate**:
|
||||
- Rust progenitor client: `cargo build -p fabro-api` (auto-runs via
|
||||
`build.rs`).
|
||||
- TypeScript client: `cd lib/packages/fabro-api-client && bun run generate`.
|
||||
- **Update fabro-web**:
|
||||
- `apps/fabro-web/app/routes/workflow-detail.tsx` has a static
|
||||
`workflowData` literal (lines 18+) typed as `RunSettings`. Rewrite each
|
||||
entry to match the new run-scope DTO shape. The live `/settings` and
|
||||
`/runs/:id/settings` routes use `JSON.stringify` and are shape-agnostic —
|
||||
they don't need code changes, just the type alignment that falls out of
|
||||
the client regen.
|
||||
- **Update server handlers**:
|
||||
- `lib/crates/fabro-server/src/server.rs` `get_server_settings` (around
|
||||
line 1062) currently serializes the flat Settings into the legacy
|
||||
`ServerSettings` shape via `serde_json::to_value` and `strip_nulls`.
|
||||
Rewrite to build the new allow-list DTO explicitly from
|
||||
`state.settings` — it must _not_ use `serde_json::to_value` on the full
|
||||
Settings, otherwise the allow-list is leaky. There is a redaction
|
||||
helper path in `fabro-types/src/settings/v2/interp.rs`
|
||||
(`Provenance::EnvSourced`) — consult it when you build the run-scope DTO.
|
||||
- `/api/v1/runs/:id/settings` currently returns `not_implemented` in the
|
||||
real (non-demo) router (grep `server.rs:1012`). The run-scope DTO rebuild
|
||||
is the same mechanical shape as the server-scope one, just different
|
||||
fields. The demo router wires `demo::get_run_settings` around
|
||||
`server.rs:934` — don't confuse the two during migration.
|
||||
|
||||
**Provenance redaction helper you'll need**:
|
||||
|
||||
`InterpString::resolve` returns a `Resolved { value, provenance }`. When
|
||||
`provenance == Provenance::EnvSourced`, the caller knows the field came
|
||||
from an env var and must redact it before serializing into the run-scope
|
||||
DTO. If you find yourself building the same `Resolved` → DTO conversion in
|
||||
multiple handlers, pull it into `fabro-types/src/settings/v2/redact.rs` as
|
||||
a new helper module.
|
||||
|
||||
## Verification recipe (run on every incremental step)
|
||||
|
||||
```bash
|
||||
# full gate — must stay green between every sub-step
|
||||
cargo fmt --check --all
|
||||
cargo build --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
ulimit -n 4096 && cargo nextest run --workspace
|
||||
cd apps/fabro-web && bun run typecheck && bun test && cd -
|
||||
|
||||
# sanity: no legacy top-level TOML keys remain in real config files
|
||||
git grep -n '^version = 1' -- docs/ lib/ apps/ fabro/ test/ | \
|
||||
grep -v 'changelog\|_version'
|
||||
|
||||
# sanity: after Stage 6.2 the bridge should have no callers
|
||||
git grep 'bridge_to_old' lib/
|
||||
```
|
||||
|
||||
## Testing gotchas I hit
|
||||
|
||||
These are lessons learned during Stages 1–5. Save yourself the pain.
|
||||
|
||||
1. **`fabro-cli` integration tests use a shared CLI test daemon under
|
||||
parallel nextest load**. Raise the shell FD limit and cap threads:
|
||||
```bash
|
||||
ulimit -n 4096
|
||||
cargo nextest run -p fabro-cli --no-fail-fast --test-threads=4
|
||||
```
|
||||
macOS inherited sessions default to `ulimit -n 256`, which surfaces as
|
||||
misleading EMFILE test timeouts.
|
||||
|
||||
2. **Insta snapshots** — when you update a snapshot, check the pending
|
||||
diffs before bulk-accepting. `cargo insta pending-snapshots` lists
|
||||
what's about to change. `cargo insta accept` accepts everything;
|
||||
`cargo insta accept --snapshot <path>` accepts one at a time. During
|
||||
Stage 4 we chose bulk accept for the run / attach JSON snapshots after
|
||||
confirming the only diffs were `server.target` + `_version` leakage
|
||||
(which we then filtered out explicitly in the per-test filter code).
|
||||
|
||||
3. **Hook shorthand vs `#[serde(flatten)]`** — the legacy
|
||||
`HookDefinition` struct has `command: Option<String>` and
|
||||
`#[serde(flatten)] hook_type: Option<HookType>`, and `HookType::Command`
|
||||
_also_ has a `command: String` field. Setting both
|
||||
`hook_type = Some(HookType::Command { command: ... })` and trying to
|
||||
serialize (or round-trip through YAML) produces a duplicate `command`
|
||||
key and fails deserialization. The bridge emits script/command hooks
|
||||
via the shorthand (`HookDefinition.command`) and leaves
|
||||
`HookDefinition.hook_type` as `None` to work around this. See commit
|
||||
`f467bd23c`.
|
||||
|
||||
4. **`fabro-test` managed settings marker** — the helper writes a
|
||||
`# fabro-test managed storage_dir` comment as the first line of
|
||||
injected settings.toml files. Functions that read the file (like
|
||||
`settings_storage_dir` for `isolated_server`) must detect the marker
|
||||
and treat the managed storage root as _not_ user-explicit, or
|
||||
`isolated_server` will pick up the shared storage dir and the test
|
||||
will fail with `assertion left != right` on the storage dir. See
|
||||
commit `b57248236`.
|
||||
|
||||
5. **`effective_settings::apply_server_defaults`** copies the **full**
|
||||
server-side Settings shape (llm, sandbox, setup, checkpoint,
|
||||
pull_request, artifacts, hooks, mcp_servers, github, slack, fabro)
|
||||
into the resolved CLI settings in RemoteServer / LocalDaemon modes.
|
||||
This is intentional — it matches the pre-Stage-3 behavior and makes
|
||||
`fabro-server::server::tests::start_run_persists_full_settings_snapshot`
|
||||
work. If you refactor the bridge during Stage 6, make sure the
|
||||
equivalent propagation lands in whatever replaces it.
|
||||
|
||||
6. **User layer trust boundary**: `effective_settings` strips `cli` and
|
||||
`server` from the `workflow.toml` and `fabro.toml` layers in
|
||||
RemoteServer / LocalDaemon modes, but **the user layer
|
||||
(`~/.fabro/settings.toml`) is never stripped** — owner-specific
|
||||
domains are only legal there. If you're tempted to strip them
|
||||
uniformly, re-read R16 and commit `2fc85282b`.
|
||||
|
||||
7. **Clippy test warnings**: `cargo clippy --workspace --tests -- -D warnings`
|
||||
has two pre-existing issues in `fabro-interview/src/control.rs`
|
||||
(absolute paths for `tokio::task::yield_now`). They're unrelated to
|
||||
the settings refactor — leave them alone or fix them in a tiny
|
||||
side-quest PR. The workspace-level (non-tests) clippy is already
|
||||
green.
|
||||
|
||||
## Open design questions for you to decide
|
||||
|
||||
1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?**
|
||||
The natural rename is `into_file(self) -> SettingsFile`, but many
|
||||
callers genuinely want a "final resolved view" that has applied env
|
||||
interpolation, applied defaults, etc. Decide whether that's an
|
||||
explicit `ResolvedSettings` type (new, v2-shaped) or whether it's
|
||||
just `SettingsFile` with a contract that consumers resolve
|
||||
`InterpString` themselves at read time.
|
||||
|
||||
2. **Post-layering env interpolation resolution pass** — the original
|
||||
plan calls for a pass in `fabro-config/src/interp_pass.rs` that
|
||||
resolves every `InterpString` in the merged `SettingsFile` using
|
||||
provenance tagging. I left this undone because `InterpString::resolve`
|
||||
is adequate for the bridge output. Stage 6 is the right moment to
|
||||
build the proper pass so the DTOs in Stage 6.6 can rely on
|
||||
provenance. Requirements R79–R81 and the "Validation Boundary"
|
||||
section of the requirements doc cover the rules.
|
||||
|
||||
3. **Fail-closed server auth posture** — R52/R53 + "Default server
|
||||
auth posture" in the plan say that if `server.auth` is absent or
|
||||
resolves to no enabled API / web auth strategies, normal server
|
||||
startup must refuse to start, with demo and test helpers free to
|
||||
opt in to insecure startup. I did not wire this into
|
||||
`fabro-server/src/server.rs`. Decide when it should land — doing it
|
||||
in the same PR as Stage 6.6 keeps auth-related changes together.
|
||||
|
||||
4. **`runtime.rs` model-ref ambiguity registry** — `ModelRef::resolve`
|
||||
takes a `&dyn ModelRegistry` and errors on ambiguous bare tokens.
|
||||
There's no runtime implementation of `ModelRegistry` yet. Decide
|
||||
whether to implement it against `fabro-model::Catalog` in Stage 6,
|
||||
or leave model-ref resolution as a consumption-time concern the
|
||||
model selector already handles.
|
||||
|
||||
5. **`run.scm.<provider>` subtree depth** — only `run.scm.github` is
|
||||
defined as a unit struct placeholder right now. Requirements R64 says
|
||||
"provider-specific details live in provider-specific nested tables".
|
||||
When the first real SCM provider leaf lands, add fields under
|
||||
`v2::run::ScmGitHubLayer` and mirror the pattern for future
|
||||
providers.
|
||||
|
||||
6. **`flatten` + `HashMap` + `deny_unknown_fields`** does NOT work
|
||||
together in serde. Every time you think "I can just flatten a
|
||||
HashMap here for provider-specific fields," resist. Use an
|
||||
enumerated list of known-provider subfields instead (that's why
|
||||
`RunSandboxLayer`, `NotificationRouteLayer`, `InterviewsLayer`,
|
||||
`RunScmLayer`, `ServerIntegrationsLayer`, etc. have explicit
|
||||
`github`/`slack`/`discord`/`teams`/`local`/`s3` fields). Adding a new
|
||||
provider means adding a new field.
|
||||
|
||||
## Repo conventions you'll hit
|
||||
|
||||
- **Rust import style** (from `CLAUDE.md`): types imported by name,
|
||||
functions via parent module, no glob imports in production code
|
||||
except in test modules. The v2 schema code follows this throughout.
|
||||
- **Shell quoting in sandbox code**: always `shell_quote()` /
|
||||
`shlex::try_quote`. Don't hand-roll `.replace('\'', "'\\''")`.
|
||||
- **Commits**: conventional style. Incremental commits per logical
|
||||
unit. Do not force-push. Do not amend. Stage 1–5 commits are the
|
||||
model.
|
||||
- **Tests**: match existing patterns in each crate. `insta` for
|
||||
snapshots. `e2e_test` attribute for dual-mode tests. Use
|
||||
`fabro_test::test_http_client()` rather than `reqwest::Client::new()`
|
||||
for local HTTP in tests (macOS proxy discovery overhead).
|
||||
|
||||
## Success criteria for Stage 6
|
||||
|
||||
The refactor is **done** when:
|
||||
|
||||
- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside of
|
||||
the legacy type file that's about to be deleted.
|
||||
- [ ] `git grep 'bridge_to_old'` returns zero hits.
|
||||
- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as a
|
||||
subdirectory — its contents are promoted to `settings/*`.
|
||||
- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted (the trait
|
||||
only existed to serve legacy flat types).
|
||||
- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs`
|
||||
are either deleted or reduced to a thin `pub use ...::v2::...`
|
||||
re-export shell, depending on your preference for the external
|
||||
surface.
|
||||
- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and
|
||||
`RunSettings` schemas are explicit allow-list DTOs, not reflections
|
||||
of the flat legacy shape.
|
||||
- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client are
|
||||
regenerated from the new spec.
|
||||
- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData`
|
||||
literal matches the new `RunSettings` DTO.
|
||||
- [ ] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` /
|
||||
`cargo nextest run --workspace` / `bun run typecheck` / `bun test`
|
||||
/ `bun run build` gates all stay green.
|
||||
|
||||
Good luck! The hard cut is behind you — Stage 6 is mechanical from
|
||||
here.
|
||||
|
|
@ -24,22 +24,29 @@ Connection-target flags like `--storage-dir` and `--server` are command-specific
|
|||
CLI defaults can be set in `~/.fabro/settings.toml` so you don't have to pass common flags every time:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[exec]
|
||||
_version = 1
|
||||
|
||||
[cli.exec.model]
|
||||
provider = "anthropic"
|
||||
model = "claude-opus-4-6"
|
||||
name = "claude-opus-4-6"
|
||||
|
||||
[cli.exec.agent]
|
||||
permissions = "read-write"
|
||||
output_format = "text"
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[cli.output]
|
||||
format = "text"
|
||||
|
||||
[server]
|
||||
target = "https://fabro.example.com:3000/api/v1"
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://fabro.example.com:3000/api/v1"
|
||||
```
|
||||
|
||||
`[exec]` config applies to `fabro exec`. `[llm]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[server]` stores connection info for commands that can target a remote Fabro server.
|
||||
`[cli.exec]` config applies to `fabro exec`. `[run.model]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[cli.target]` stores connection info for commands that can target a remote Fabro server.
|
||||
|
||||
`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[server].target` is configured.
|
||||
`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[cli.target]` is configured.
|
||||
|
||||
CLI flags always override `settings.toml` values, which override hardcoded defaults.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,114 +17,167 @@ The default path is `~/.fabro/settings.toml`.
|
|||
|
||||
Use `fabro server start --config /path/to/settings.toml` if the server should read a different file.
|
||||
|
||||
## Schema version
|
||||
|
||||
Every Fabro config file must declare its schema version with a top-level `_version` key:
|
||||
|
||||
```toml title="settings.toml"
|
||||
_version = 1
|
||||
```
|
||||
|
||||
Files that omit `_version` are treated as version `1`. The legacy top-level `version` key is no longer accepted and raises a targeted rename hint.
|
||||
|
||||
## Who reads what
|
||||
|
||||
`settings.toml` uses the same schema as `fabro.toml` and `workflow.toml`, but each process only reads the fields it understands.
|
||||
`settings.toml` uses the same schema as `fabro.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`.
|
||||
|
||||
| Scope | Examples |
|
||||
|---|---|
|
||||
| CLI-only | `verbose`, `upgrade_check`, `[server]`, `[exec]`, `[mcp_servers]` |
|
||||
| Shared defaults | `[llm]`, `[log]`, `[git]`, `[pull_request]`, plus run-default sections like `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` |
|
||||
| Server-only | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` |
|
||||
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
|
||||
| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.hooks]`, `[run.agent.mcps]` |
|
||||
| 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.toml` or `workflow.toml` remain schema-valid but runtime-inert.
|
||||
|
||||
See [Server Configuration](/administration/server-configuration) for the server-owned sections.
|
||||
|
||||
## Precedence
|
||||
|
||||
For CLI commands running on the local machine, precedence is:
|
||||
Shared layered domains (`[project]`, `[workflow]`, `[run]`, `[features]`) use this override order:
|
||||
|
||||
1. **CLI flags** — always win
|
||||
2. **`workflow.toml` / `run.toml`** — per-run overrides
|
||||
3. **`fabro.toml`** — project defaults
|
||||
4. **`settings.toml`** — machine defaults
|
||||
5. **Built-in defaults**
|
||||
2. **Environment overrides** — Fabro-defined override channels
|
||||
3. **`workflow.toml`** — per-workflow overrides
|
||||
4. **`fabro.toml`** — project defaults
|
||||
5. **`~/.fabro/settings.toml`** — machine defaults
|
||||
6. **Built-in defaults**
|
||||
|
||||
On a same-machine setup, the server reads the same `settings.toml`. On a remote setup, the CLI machine and server machine each use their own local `settings.toml`.
|
||||
Owner-specific domains (`[cli.*]`, `[server.*]`) use a narrower trust boundary — only CLI flags, env overrides, `~/.fabro/settings.toml`, and built-in defaults apply.
|
||||
|
||||
## Full example
|
||||
|
||||
```toml title="settings.toml"
|
||||
verbose = true
|
||||
upgrade_check = true
|
||||
_version = 1
|
||||
|
||||
[server]
|
||||
target = "https://fabro.example.com:3000/api/v1"
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://fabro.example.com:3000/api/v1"
|
||||
|
||||
[server.tls]
|
||||
[cli.target.tls]
|
||||
cert = "~/.fabro/tls/client.crt"
|
||||
key = "~/.fabro/tls/client.key"
|
||||
ca = "~/.fabro/tls/ca.crt"
|
||||
|
||||
[exec]
|
||||
[cli.exec]
|
||||
prevent_idle_sleep = true
|
||||
|
||||
[cli.exec.model]
|
||||
provider = "anthropic"
|
||||
model = "claude-opus-4-6"
|
||||
name = "claude-opus-4-6"
|
||||
|
||||
[cli.exec.agent]
|
||||
permissions = "read-write"
|
||||
output_format = "text"
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-5"
|
||||
[cli.output]
|
||||
format = "text"
|
||||
verbosity = "normal"
|
||||
|
||||
[log]
|
||||
[cli.updates]
|
||||
check = true
|
||||
|
||||
[cli.logging]
|
||||
level = "info"
|
||||
|
||||
[git.author]
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-5"
|
||||
|
||||
[run.git.author]
|
||||
name = "fabro-bot"
|
||||
email = "fabro-bot@company.com"
|
||||
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.filesystem]
|
||||
[run.agent.mcps.filesystem]
|
||||
type = "stdio"
|
||||
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
startup_timeout_secs = 15
|
||||
tool_timeout_secs = 90
|
||||
startup_timeout = "15s"
|
||||
tool_timeout = "90s"
|
||||
|
||||
[mcp_servers.filesystem.env]
|
||||
[run.agent.mcps.filesystem.env]
|
||||
NODE_ENV = "production"
|
||||
|
||||
[mcp_servers.sentry]
|
||||
[run.agent.mcps.sentry]
|
||||
type = "http"
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
|
||||
[mcp_servers.sentry.headers]
|
||||
[run.agent.mcps.sentry.headers]
|
||||
Authorization = "Bearer sk-xxx"
|
||||
```
|
||||
|
||||
All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[web]` and `[api]`.
|
||||
All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[server.web]` and `[server.api]`.
|
||||
|
||||
## `upgrade_check`
|
||||
## `[cli.updates]`
|
||||
|
||||
Controls whether Fabro runs a daily background check for new releases. The check runs during `run`, `exec`, `init`, and `install` commands and prints a notice to stderr when a newer version is available.
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `true` | Check for new releases (default) |
|
||||
| `false` | Disable automatic upgrade checks |
|
||||
```toml title="settings.toml"
|
||||
[cli.updates]
|
||||
check = true
|
||||
```
|
||||
|
||||
| Key | Value | Description |
|
||||
|---|---|---|
|
||||
| `check` | `true` | Check for new releases (default) |
|
||||
| `check` | `false` | Disable automatic upgrade checks |
|
||||
|
||||
The `--no-upgrade-check` CLI flag overrides this for a single invocation. See [`fabro upgrade`](/reference/cli#fabro-upgrade) for manual upgrades.
|
||||
|
||||
## `verbose`
|
||||
## `[cli.output]`
|
||||
|
||||
Enable verbose output by default for `fabro run start` and `fabro doctor`, without passing `-v` every time.
|
||||
Generic CLI output defaults.
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `true` | Verbose output on by default |
|
||||
| `false` | Normal output (default) |
|
||||
```toml title="settings.toml"
|
||||
[cli.output]
|
||||
format = "text"
|
||||
verbosity = "verbose"
|
||||
```
|
||||
|
||||
| Key | Values | Default |
|
||||
|---|---|---|
|
||||
| `format` | `"text"`, `"json"` | `"text"` |
|
||||
| `verbosity` | `"quiet"`, `"normal"`, `"verbose"` | `"normal"` |
|
||||
|
||||
The `-v` / `--verbose` CLI flag always takes effect regardless of this setting.
|
||||
|
||||
## `[exec]` section
|
||||
## `[cli.exec]` section
|
||||
|
||||
Defaults for `fabro exec` sessions.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[cli.exec]
|
||||
prevent_idle_sleep = true
|
||||
|
||||
[cli.exec.model]
|
||||
provider = "anthropic"
|
||||
name = "claude-opus-4-6"
|
||||
|
||||
[cli.exec.agent]
|
||||
permissions = "read-write"
|
||||
```
|
||||
|
||||
`[cli.exec.model]` selects the default LLM for exec:
|
||||
|
||||
| Key | Description | Values |
|
||||
|---|---|---|
|
||||
| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. |
|
||||
| `name` | Model name | Any model ID from `fabro model list` |
|
||||
|
||||
`[cli.exec.agent]` controls agent behavior during exec:
|
||||
|
||||
| Key | Description | Values | Default |
|
||||
|---|---|---|---|
|
||||
| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. | `"anthropic"` |
|
||||
| `model` | Model name | Any model ID from `fabro model list` | Per provider |
|
||||
| `permissions` | Tool permission level | `"read-only"`, `"read-write"`, `"full"` | `"read-write"` |
|
||||
| `output_format` | Output format | `"text"`, `"json"` | `"text"` |
|
||||
|
||||
### Permission levels
|
||||
|
||||
|
|
@ -134,62 +187,91 @@ Defaults for `fabro exec` sessions.
|
|||
|
||||
Tools outside the permission level are interactively prompted (if a TTY is present) or denied (with `--auto-approve`).
|
||||
|
||||
### Output formats
|
||||
|
||||
- **`text`** — human-readable terminal output
|
||||
- **`json`** — NDJSON event stream
|
||||
|
||||
## `[llm]` section
|
||||
## `[run.model]` section
|
||||
|
||||
Defaults for workflow model selection in commands like `fabro run` and `fabro preflight`.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[run.model]
|
||||
provider = "anthropic"
|
||||
name = "claude-sonnet-4-5"
|
||||
fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"]
|
||||
```
|
||||
|
||||
| Key | Description | Values | Default |
|
||||
|---|---|---|---|
|
||||
| `model` | Model name | Any model ID from `fabro model list` | Per provider |
|
||||
| `name` | Model name | Any model ID from `fabro model list` | Per provider |
|
||||
| `provider` | Provider name | `"anthropic"`, `"openai"`, `"gemini"`, etc. | Auto-inferred from model/catalog |
|
||||
| `fallbacks` | Ordered list of fallback model references | bare provider, bare alias, or `provider/model` | `[]` |
|
||||
|
||||
<Note>
|
||||
Use `[exec]` to configure provider, permissions, and output format for `fabro exec`. Use `[llm]` for workflow-oriented defaults.
|
||||
Use `[cli.exec.model]` to configure provider and model for `fabro exec`. Use `[run.model]` for workflow-oriented defaults.
|
||||
</Note>
|
||||
|
||||
## `[log]` section
|
||||
## `[cli.logging]` section
|
||||
|
||||
Configure the default log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`.
|
||||
Configure the default CLI log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[cli.logging].level` > `"info"`.
|
||||
|
||||
| Key | Description | Values | Default |
|
||||
|---|---|---|---|
|
||||
| `level` | Log level | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` |
|
||||
```toml title="settings.toml"
|
||||
[cli.logging]
|
||||
level = "info"
|
||||
```
|
||||
|
||||
## `[git]` section
|
||||
| Key | Values | Default |
|
||||
|---|---|---|
|
||||
| `level` | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` |
|
||||
|
||||
### `[git.author]`
|
||||
Server-side logging is a separate namespace at `[server.logging]`.
|
||||
|
||||
Customize the git author identity used for checkpoint commits. On same-machine setups, the CLI and server read the same `[git.author]` value. On remote setups, each machine uses its own local `settings.toml`.
|
||||
## `[run.git.author]`
|
||||
|
||||
Customize the git author identity used for checkpoint commits.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[run.git.author]
|
||||
name = "fabro-bot"
|
||||
email = "fabro-bot@company.com"
|
||||
```
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `name` | Git author name | `"fabro"` |
|
||||
| `email` | Git author email | `"fabro@local"` |
|
||||
|
||||
## `[server]` section
|
||||
## `[cli.target]` section
|
||||
|
||||
Connection info for commands that target a remote Fabro server.
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `target` | Server target: `http(s)` URL or absolute Unix socket path | none |
|
||||
```toml title="settings.toml"
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://fabro.example.com:3000/api/v1"
|
||||
```
|
||||
|
||||
`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides `server.target`:
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
| `type` | `"http"` or `"unix"` — explicit transport selection |
|
||||
| `url` | Required for `type = "http"` — the API base URL |
|
||||
| `path` | Required for `type = "unix"` — the absolute Unix socket path |
|
||||
|
||||
`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides the configured target:
|
||||
|
||||
```bash
|
||||
fabro model list --server https://fabro.example.com:3000/api/v1
|
||||
```
|
||||
|
||||
`fabro exec` does not automatically use `[server].target`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation.
|
||||
`fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation.
|
||||
|
||||
### `[server.tls]` section
|
||||
### `[cli.target.tls]` section
|
||||
|
||||
Optional mTLS configuration for authenticating with the server. When present, the CLI presents a client certificate during the TLS handshake.
|
||||
Optional mTLS configuration for authenticating with an HTTP target. When present, the CLI presents a client certificate during the TLS handshake.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[cli.target.tls]
|
||||
cert = "~/.fabro/tls/client.crt"
|
||||
key = "~/.fabro/tls/client.key"
|
||||
ca = "~/.fabro/tls/ca.crt"
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
|
|
@ -197,46 +279,42 @@ Optional mTLS configuration for authenticating with the server. When present, th
|
|||
| `key` | Path to client private key PEM file |
|
||||
| `ca` | Path to CA certificate PEM file (to verify the server) |
|
||||
|
||||
Paths support `~/` expansion. Example:
|
||||
Paths support `~/` expansion.
|
||||
|
||||
## `[run.pull_request]`
|
||||
|
||||
Enable auto-PR globally so workflows open a GitHub pull request on successful completion.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[server.tls]
|
||||
cert = "~/.fabro/tls/client.crt"
|
||||
key = "~/.fabro/tls/client.key"
|
||||
ca = "~/.fabro/tls/ca.crt"
|
||||
```
|
||||
|
||||
## `[pull_request]`
|
||||
|
||||
Enable auto-PR globally so workflows open a GitHub pull request on successful completion, even when running with a `.fabro` file instead of a `run.toml`.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
| Key | Description | Default |
|
||||
|---|---|---|
|
||||
| `enabled` | Automatically create a PR after successful runs | `false` |
|
||||
| `draft` | Open the PR as a draft | `true` |
|
||||
| `auto_merge` | Enable GitHub auto-merge on the created PR (implies `draft = false`) | `false` |
|
||||
| `merge_strategy` | One of `"squash"`, `"merge"`, `"rebase"` | `"squash"` |
|
||||
|
||||
Precedence: `run.toml` > `fabro.toml` > `settings.toml` > built-in default (`false`).
|
||||
Precedence: `workflow.toml` > `fabro.toml` > `~/.fabro/settings.toml` > built-in default (`false`).
|
||||
|
||||
## `[mcp_servers]` section
|
||||
## `[run.agent.mcps]` section
|
||||
|
||||
Configure [MCP servers](/agents/mcp) to connect to during `fabro exec` sessions. Each server is a named TOML table under `[mcp_servers]`. MCP servers can also be configured per-workflow in [run config TOML](/execution/run-configuration#mcp_servers).
|
||||
Configure [MCP servers](/agents/mcp) to connect to during agent-driven runs. Each server is a named TOML table under `[run.agent.mcps]`. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.*]` with the same shape.
|
||||
|
||||
### Stdio transport
|
||||
|
||||
Spawn a local process and communicate over stdin/stdout:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[mcp_servers.filesystem]
|
||||
[run.agent.mcps.filesystem]
|
||||
type = "stdio"
|
||||
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
startup_timeout_secs = 15
|
||||
tool_timeout_secs = 90
|
||||
startup_timeout = "15s"
|
||||
tool_timeout = "90s"
|
||||
|
||||
[mcp_servers.filesystem.env]
|
||||
[run.agent.mcps.filesystem.env]
|
||||
NODE_ENV = "production"
|
||||
```
|
||||
|
||||
|
|
@ -245,19 +323,19 @@ NODE_ENV = "production"
|
|||
| `type` | Must be `"stdio"` | — |
|
||||
| `command` | Array: executable + arguments | — |
|
||||
| `env` | Additional environment variables for the child process | `{}` |
|
||||
| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` |
|
||||
| `tool_timeout_secs` | Max seconds for a single tool call | `60` |
|
||||
| `startup_timeout` | Max duration for the MCP handshake (e.g. `"10s"`, `"30s"`) | `"10s"` |
|
||||
| `tool_timeout` | Max duration for a single tool call (e.g. `"60s"`, `"2m"`) | `"60s"` |
|
||||
|
||||
### HTTP transport
|
||||
|
||||
Connect to a remote MCP server over Streamable HTTP:
|
||||
|
||||
```toml title="settings.toml"
|
||||
[mcp_servers.sentry]
|
||||
[run.agent.mcps.sentry]
|
||||
type = "http"
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
|
||||
[mcp_servers.sentry.headers]
|
||||
[run.agent.mcps.sentry.headers]
|
||||
Authorization = "Bearer sk-xxx"
|
||||
```
|
||||
|
||||
|
|
@ -266,20 +344,20 @@ Authorization = "Bearer sk-xxx"
|
|||
| `type` | Must be `"http"` | — |
|
||||
| `url` | The MCP server endpoint URL | — |
|
||||
| `headers` | Optional HTTP headers (for example, for authentication) | `{}` |
|
||||
| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` |
|
||||
| `tool_timeout_secs` | Max seconds for a single tool call | `60` |
|
||||
| `startup_timeout` | Max duration for the MCP handshake | `"10s"` |
|
||||
| `tool_timeout` | Max duration for a single tool call | `"60s"` |
|
||||
|
||||
### Sandbox transport
|
||||
|
||||
Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configure this in [run config TOML](/execution/run-configuration#mcp_servers) rather than `settings.toml`.
|
||||
Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in `workflow.toml` rather than `settings.toml`:
|
||||
|
||||
```toml title="run.toml"
|
||||
[mcp_servers.playwright]
|
||||
```toml title="workflow.toml"
|
||||
[run.agent.mcps.playwright]
|
||||
type = "sandbox"
|
||||
command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"]
|
||||
port = 3100
|
||||
startup_timeout_secs = 60
|
||||
tool_timeout_secs = 120
|
||||
startup_timeout = "60s"
|
||||
tool_timeout = "2m"
|
||||
```
|
||||
|
||||
| Key | Description | Default |
|
||||
|
|
@ -288,7 +366,7 @@ tool_timeout_secs = 120
|
|||
| `command` | Array: the command to run inside the sandbox | — |
|
||||
| `port` | Port the server listens on inside the sandbox | — |
|
||||
| `env` | Additional environment variables for the server process | `{}` |
|
||||
| `startup_timeout_secs` | Max seconds for startup + MCP handshake | `10` |
|
||||
| `tool_timeout_secs` | Max seconds for a single tool call | `60` |
|
||||
| `startup_timeout` | Max duration for startup + MCP handshake | `"10s"` |
|
||||
| `tool_timeout` | Max duration for a single tool call | `"60s"` |
|
||||
|
||||
See [MCP — Sandbox transport](/agents/mcp#sandbox) for how Fabro launches and connects to sandbox MCP servers.
|
||||
|
|
|
|||
|
|
@ -5,22 +5,26 @@ description: "Using variables in workflows"
|
|||
|
||||
Fabro supports `$variable` placeholders that let you parameterize workflows without editing the Graphviz file.
|
||||
|
||||
## Run config variables
|
||||
## Run config inputs
|
||||
|
||||
Define variables in the `[vars]` section of a run config TOML file:
|
||||
Define inputs in the `[run.inputs]` section of a run config TOML file:
|
||||
|
||||
```toml title="run.toml"
|
||||
version = 1
|
||||
goal = "Run tests for $repo_name"
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "check.fabro"
|
||||
|
||||
[vars]
|
||||
[run]
|
||||
goal = "Run tests for $repo_name"
|
||||
|
||||
[run.inputs]
|
||||
repo_name = "fabro"
|
||||
repo_url = "https://github.com/fabro-sh/fabro"
|
||||
language = "rust"
|
||||
```
|
||||
|
||||
These variables are expanded into the Graphviz source **before** the graph is parsed. You can use `$variable` anywhere in the Graphviz file — goals, prompts, labels, scripts, or any other attribute:
|
||||
These inputs are expanded into the Graphviz source **before** the graph is parsed. You can use `$variable` anywhere in the Graphviz file — goals, prompts, labels, scripts, or any other attribute:
|
||||
|
||||
```dot title="check.fabro"
|
||||
digraph Check {
|
||||
|
|
@ -40,7 +44,7 @@ When launched with `fabro run run.toml`, Fabro replaces `$repo_name`, `$repo_url
|
|||
|
||||
### Undefined variables
|
||||
|
||||
If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM.
|
||||
If a `$variable` in the Graphviz file has no matching entry in `[run.inputs]`, Fabro raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM.
|
||||
|
||||
### Escaping `$`
|
||||
|
||||
|
|
@ -66,11 +70,15 @@ digraph Example {
|
|||
|
||||
The plan node's prompt becomes `"Create a plan for: Implement the login feature"`.
|
||||
|
||||
## Variable merging
|
||||
## Input merging
|
||||
|
||||
When using server-level run defaults alongside a run config TOML, variables are merged. Task config vars override default vars when keys collide:
|
||||
`[run.inputs]` intentionally replaces the inherited map wholesale rather than merging by key. Whichever layer has the highest precedence and sets `[run.inputs]` wins its entire map — lower-precedence inputs do not show through.
|
||||
|
||||
| Source | Priority |
|
||||
|---|---|
|
||||
| Run config TOML `[vars]` | Highest — wins on collision |
|
||||
| Server defaults `[vars]` | Lowest — provides fallback values |
|
||||
| CLI flags (`-V key=value`, repeated) | Highest |
|
||||
| `workflow.toml` `[run.inputs]` | |
|
||||
| `fabro.toml` `[run.inputs]` | |
|
||||
| `~/.fabro/settings.toml` `[run.inputs]` | Lowest |
|
||||
|
||||
If you need per-key overrides on top of inherited defaults, set each input explicitly in the winning layer.
|
||||
|
|
|
|||
28
fabro.toml
28
fabro.toml
|
|
@ -1,29 +1,26 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
||||
[fabro]
|
||||
root = "fabro/"
|
||||
[project]
|
||||
directory = "fabro/"
|
||||
|
||||
[features]
|
||||
retros = false
|
||||
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = false
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 30
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
repo = "fabro-sh/fabro"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "fabro-v6"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = """
|
||||
FROM ubuntu:24.04
|
||||
|
||||
|
|
@ -52,9 +49,10 @@ ENV PATH="/root/.bun/bin:${PATH}"
|
|||
WORKDIR /root
|
||||
"""
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "cargo-fmt"
|
||||
name = "cargo-fmt"
|
||||
event = "post_tool_use"
|
||||
matcher = "write_file|edit_file|apply_patch"
|
||||
command = "cargo fmt"
|
||||
script = "cargo fmt"
|
||||
blocking = true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
||||
[github]
|
||||
permissions = { pull_requests = "read", issues = "read" }
|
||||
[server.integrations.github]
|
||||
|
||||
[server.integrations.github.permissions]
|
||||
pull_requests = "read"
|
||||
issues = "read"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
||||
[github]
|
||||
permissions = { issues = "read", pull_requests = "write" }
|
||||
[server.integrations.github]
|
||||
|
||||
[server.integrations.github.permissions]
|
||||
issues = "read"
|
||||
pull_requests = "write"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ workspace = true
|
|||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
fabro-config = { path = "../fabro-config", features = ["clap"] }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
|
|
|
|||
|
|
@ -68,7 +68,26 @@ struct Cli {
|
|||
args: AgentArgs,
|
||||
}
|
||||
|
||||
pub use fabro_config::user::{OutputFormat, PermissionLevel};
|
||||
/// Output format for the `fabro exec` / agent CLI.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Agent tool permission level.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PermissionLevel {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl AgentArgs {
|
||||
/// Fill `None` fields from settings.toml values, then hardcoded defaults.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use fabro_types::settings::server::GitAuthorSettings;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::GitAuthorLayer;
|
||||
|
||||
/// Resolved git author identity for checkpoint commits.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
@ -49,8 +50,11 @@ impl GitAuthor {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<&GitAuthorSettings> for GitAuthor {
|
||||
fn from(value: &GitAuthorSettings) -> Self {
|
||||
Self::from_options(value.name.clone(), value.email.clone())
|
||||
impl From<&GitAuthorLayer> for GitAuthor {
|
||||
fn from(value: &GitAuthorLayer) -> Self {
|
||||
Self::from_options(
|
||||
value.name.as_ref().map(InterpString::as_source),
|
||||
value.email.as_ref().map(InterpString::as_source),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,8 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::{Graph, Settings, fixtures};
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::{Graph, fixtures};
|
||||
|
||||
/// Create a temporary git repo with an initial commit.
|
||||
fn init_repo(dir: &Path) {
|
||||
|
|
@ -206,7 +207,7 @@ mod tests {
|
|||
fn test_run_record(run_id: fabro_types::RunId) -> RunRecord {
|
||||
RunRecord {
|
||||
run_id,
|
||||
settings: Settings::default(),
|
||||
settings: SettingsFile::default(),
|
||||
graph: Graph::new("test"),
|
||||
workflow_slug: None,
|
||||
working_directory: PathBuf::from("/tmp"),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::args::{ServerConnectionArgs, ServerTargetArgs};
|
||||
|
|
@ -24,7 +24,7 @@ pub(crate) enum ServerMode {
|
|||
pub(crate) struct CommandContext {
|
||||
cwd: PathBuf,
|
||||
base_config_path: PathBuf,
|
||||
machine_settings: Settings,
|
||||
machine_settings: SettingsFile,
|
||||
server_mode: ServerMode,
|
||||
server: OnceCell<Arc<ServerStoreClient>>,
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ impl CommandContext {
|
|||
&self.base_config_path
|
||||
}
|
||||
|
||||
pub(crate) fn machine_settings(&self) -> &Settings {
|
||||
pub(crate) fn machine_settings(&self) -> &SettingsFile {
|
||||
&self.machine_settings
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use fabro_config::ConfigLayer;
|
|||
use fabro_config::effective_settings;
|
||||
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
|
||||
use fabro_config::project;
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
|
||||
fn config_layers(
|
||||
ctx: &CommandContext,
|
||||
|
|
@ -57,7 +57,7 @@ fn workflow_and_project_layers(
|
|||
Ok((workflow_layer, project_layer))
|
||||
}
|
||||
|
||||
async fn merged_config(args: &SettingsArgs) -> anyhow::Result<Settings> {
|
||||
async fn merged_config(args: &SettingsArgs) -> anyhow::Result<SettingsFile> {
|
||||
let base_ctx = CommandContext::base()?;
|
||||
let layers = config_layers(&base_ctx, args.workflow.as_deref())?;
|
||||
if args.local {
|
||||
|
|
@ -80,7 +80,7 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result<Settings> {
|
|||
}
|
||||
|
||||
pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
|
||||
let config = merged_config(args).await?;
|
||||
let config = Box::pin(merged_config(args)).await?;
|
||||
if globals.json {
|
||||
print_json_pretty(&config)?;
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client};
|
||||
use fabro_config::mcp::McpServerEntry;
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::providers::FabroServerAdapter;
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_mcp::config::{McpServerSettings, bridge_mcp_entry};
|
||||
use fabro_types::settings::InterpString;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -11,25 +11,52 @@ use crate::args::{ExecArgs, GlobalArgs};
|
|||
use crate::user_config;
|
||||
|
||||
pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
use fabro_agent::cli::PermissionLevel as AgentPermissionLevel;
|
||||
use fabro_types::settings::run::AgentPermissions;
|
||||
|
||||
let cli_settings = user_config::load_settings()?;
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled());
|
||||
let exec_defaults = cli_settings.exec.as_ref();
|
||||
let exec_defaults = cli_settings.cli_exec();
|
||||
let exec_model = exec_defaults.and_then(|e| e.model.as_ref());
|
||||
let exec_agent = exec_defaults.and_then(|e| e.agent.as_ref());
|
||||
let provider_str = exec_model
|
||||
.and_then(|m| m.provider.as_ref())
|
||||
.map(InterpString::as_source);
|
||||
let model_str = exec_model
|
||||
.and_then(|m| m.name.as_ref())
|
||||
.map(InterpString::as_source);
|
||||
let permissions = exec_agent
|
||||
.and_then(|agent| agent.permissions)
|
||||
.map(|p| match p {
|
||||
AgentPermissions::ReadOnly => AgentPermissionLevel::ReadOnly,
|
||||
AgentPermissions::ReadWrite => AgentPermissionLevel::ReadWrite,
|
||||
AgentPermissions::Full => AgentPermissionLevel::Full,
|
||||
});
|
||||
args.agent.apply_cli_defaults(
|
||||
exec_defaults.and_then(|a| a.provider.as_deref()),
|
||||
exec_defaults.and_then(|a| a.model.as_deref()),
|
||||
exec_defaults.and_then(|a| a.permissions),
|
||||
exec_defaults.and_then(|a| a.output_format),
|
||||
provider_str.as_deref(),
|
||||
model_str.as_deref(),
|
||||
permissions,
|
||||
None,
|
||||
);
|
||||
if globals.json {
|
||||
args.agent.output_format = Some(OutputFormat::Json);
|
||||
}
|
||||
let server_target = user_config::exec_server_target(&args.server, &cli_settings)?;
|
||||
let mcp_servers: Vec<McpServerSettings> = cli_settings
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.map(|(name, entry): (String, McpServerEntry)| entry.into_config(name))
|
||||
.collect();
|
||||
// v2 MCPs live under `cli.exec.agent.mcps` (owner-specific) or
|
||||
// `run.agent.mcps`. For `fabro exec` we use the cli.exec path, falling
|
||||
// back to run.agent.mcps if unset.
|
||||
let mcps_iter = exec_agent
|
||||
.map(|a| &a.mcps)
|
||||
.filter(|m| !m.is_empty())
|
||||
.or_else(|| cli_settings.run_agent_mcps());
|
||||
let mcp_servers: Vec<McpServerSettings> = mcps_iter
|
||||
.map(|mcps| {
|
||||
mcps.iter()
|
||||
.map(|(name, entry)| bridge_mcp_entry(entry).into_config(name.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(target) = server_target {
|
||||
tracing::info!(transport = "server", "Agent session starting");
|
||||
let provider_name = args
|
||||
|
|
|
|||
|
|
@ -205,50 +205,53 @@ fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut tom
|
|||
|
||||
fn merge_server_settings(doc: &mut toml::Value, username: &str) -> Result<()> {
|
||||
let root = root_table_mut(doc)?;
|
||||
let web = ensure_table(root, "web")?;
|
||||
root.insert("_version".to_string(), toml::Value::Integer(1));
|
||||
|
||||
let server = ensure_table(root, "server")?;
|
||||
|
||||
let api = ensure_table(server, "api")?;
|
||||
api.insert(
|
||||
"url".to_string(),
|
||||
toml::Value::String("https://localhost:3000/api/v1".to_string()),
|
||||
);
|
||||
|
||||
let listen = ensure_table(server, "listen")?;
|
||||
listen.insert("type".to_string(), toml::Value::String("tcp".to_string()));
|
||||
let listen_tls = ensure_table(listen, "tls")?;
|
||||
let certs_dir = fabro_util::Home::from_env().certs_dir();
|
||||
listen_tls.insert(
|
||||
"cert".to_string(),
|
||||
toml::Value::String(certs_dir.join("server.crt").to_string_lossy().to_string()),
|
||||
);
|
||||
listen_tls.insert(
|
||||
"key".to_string(),
|
||||
toml::Value::String(certs_dir.join("server.key").to_string_lossy().to_string()),
|
||||
);
|
||||
listen_tls.insert(
|
||||
"ca".to_string(),
|
||||
toml::Value::String(certs_dir.join("ca.crt").to_string_lossy().to_string()),
|
||||
);
|
||||
|
||||
let web = ensure_table(server, "web")?;
|
||||
web.insert("enabled".to_string(), toml::Value::Boolean(true));
|
||||
web.insert(
|
||||
"url".to_string(),
|
||||
toml::Value::String("http://localhost:3000".to_string()),
|
||||
);
|
||||
|
||||
let auth = ensure_table(web, "auth")?;
|
||||
auth.insert(
|
||||
"provider".to_string(),
|
||||
toml::Value::String("github".to_string()),
|
||||
);
|
||||
auth.insert(
|
||||
let auth = ensure_table(server, "auth")?;
|
||||
let auth_api = ensure_table(auth, "api")?;
|
||||
let jwt = ensure_table(auth_api, "jwt")?;
|
||||
jwt.insert("enabled".to_string(), toml::Value::Boolean(true));
|
||||
let mtls = ensure_table(auth_api, "mtls")?;
|
||||
mtls.insert("enabled".to_string(), toml::Value::Boolean(true));
|
||||
|
||||
let auth_web = ensure_table(auth, "web")?;
|
||||
auth_web.insert(
|
||||
"allowed_usernames".to_string(),
|
||||
toml::Value::Array(vec![toml::Value::String(username.to_string())]),
|
||||
);
|
||||
|
||||
let api = ensure_table(root, "api")?;
|
||||
api.insert(
|
||||
"base_url".to_string(),
|
||||
toml::Value::String("https://localhost:3000/api/v1".to_string()),
|
||||
);
|
||||
api.insert(
|
||||
"authentication_strategies".to_string(),
|
||||
toml::Value::Array(vec![
|
||||
toml::Value::String("jwt".to_string()),
|
||||
toml::Value::String("mtls".to_string()),
|
||||
]),
|
||||
);
|
||||
|
||||
let tls = ensure_table(api, "tls")?;
|
||||
let certs_dir = fabro_util::Home::from_env().certs_dir();
|
||||
tls.insert(
|
||||
"cert".to_string(),
|
||||
toml::Value::String(certs_dir.join("server.crt").to_string_lossy().to_string()),
|
||||
);
|
||||
tls.insert(
|
||||
"key".to_string(),
|
||||
toml::Value::String(certs_dir.join("server.key").to_string_lossy().to_string()),
|
||||
);
|
||||
tls.insert(
|
||||
"ca".to_string(),
|
||||
toml::Value::String(certs_dir.join("ca.crt").to_string_lossy().to_string()),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1114,102 +1117,114 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn config_toml_roundtrips() {
|
||||
use fabro_types::settings::SettingsFile;
|
||||
let toml_str = format_config_toml("brynary");
|
||||
let settings: fabro_types::Settings =
|
||||
toml::from_str(&toml_str).expect("config should parse");
|
||||
assert_eq!(
|
||||
settings.web.unwrap().auth.allowed_usernames,
|
||||
vec!["brynary"]
|
||||
);
|
||||
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str)
|
||||
.expect("generated config should parse as v2")
|
||||
.into();
|
||||
let allowed = cfg
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.auth.as_ref())
|
||||
.and_then(|a| a.web.as_ref())
|
||||
.map(|w| w.allowed_usernames.clone())
|
||||
.expect("server.auth.web.allowed_usernames should be set");
|
||||
assert_eq!(allowed, vec!["brynary".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_has_auth_strategies() {
|
||||
use fabro_types::settings::SettingsFile;
|
||||
let toml_str = format_config_toml("alice");
|
||||
let settings: fabro_types::Settings = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(
|
||||
settings.api.unwrap().authentication_strategies,
|
||||
vec![
|
||||
fabro_config::server::ApiAuthStrategy::Jwt,
|
||||
fabro_config::server::ApiAuthStrategy::Mtls,
|
||||
]
|
||||
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into();
|
||||
let auth_api = cfg
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.auth.as_ref())
|
||||
.and_then(|a| a.api.as_ref())
|
||||
.expect("server.auth.api should be set");
|
||||
assert!(
|
||||
auth_api
|
||||
.jwt
|
||||
.as_ref()
|
||||
.is_some_and(|jwt| jwt.enabled.unwrap_or(false))
|
||||
);
|
||||
assert!(
|
||||
auth_api
|
||||
.mtls
|
||||
.as_ref()
|
||||
.is_some_and(|mtls| mtls.enabled.unwrap_or(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_has_tls_paths() {
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::settings::server::ServerListenLayer;
|
||||
let toml_str = format_config_toml("bob");
|
||||
let settings: fabro_types::Settings = toml::from_str(&toml_str).unwrap();
|
||||
let tls = settings.api.unwrap().tls.expect("tls should be set");
|
||||
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into();
|
||||
let listen = cfg
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.listen.as_ref())
|
||||
.expect("server.listen should be set");
|
||||
let tls = match listen {
|
||||
ServerListenLayer::Tcp { tls, .. } => tls.as_ref().expect("server.listen.tls"),
|
||||
ServerListenLayer::Unix { .. } => panic!("expected tcp listen"),
|
||||
};
|
||||
let certs_dir = fabro_util::Home::from_env().certs_dir();
|
||||
assert_eq!(tls.cert, certs_dir.join("server.crt"));
|
||||
assert_eq!(tls.key, certs_dir.join("server.key"));
|
||||
assert_eq!(tls.ca, certs_dir.join("ca.crt"));
|
||||
assert_eq!(
|
||||
tls.cert.as_ref().map(|c| c.as_source()),
|
||||
Some(certs_dir.join("server.crt").to_string_lossy().into_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
tls.key.as_ref().map(|c| c.as_source()),
|
||||
Some(certs_dir.join("server.key").to_string_lossy().into_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
tls.ca.as_ref().map(|c| c.as_source()),
|
||||
Some(certs_dir.join("ca.crt").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_server_settings_preserves_existing_git_table() {
|
||||
fn merge_server_settings_preserves_existing_top_level_sections() {
|
||||
let mut doc: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[git]
|
||||
app_id = "123"
|
||||
_version = 1
|
||||
|
||||
[git.author]
|
||||
name = "fabro"
|
||||
email = "fabro@example.com"
|
||||
[project]
|
||||
name = "custom"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_server_settings(&mut doc, "alice").unwrap();
|
||||
|
||||
let git = doc.get("git").and_then(toml::Value::as_table).unwrap();
|
||||
assert_eq!(git.get("app_id").and_then(toml::Value::as_str), Some("123"));
|
||||
let author = git.get("author").and_then(toml::Value::as_table).unwrap();
|
||||
// Existing top-level [project] stays.
|
||||
assert_eq!(
|
||||
author.get("name").and_then(toml::Value::as_str),
|
||||
Some("fabro")
|
||||
);
|
||||
assert_eq!(
|
||||
author.get("email").and_then(toml::Value::as_str),
|
||||
Some("fabro@example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get("web")
|
||||
doc.get("project")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|web| web.get("auth"))
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("custom")
|
||||
);
|
||||
// New server.auth.web.allowed_usernames is added.
|
||||
assert_eq!(
|
||||
doc.get("server")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|auth| auth.get("allowed_usernames"))
|
||||
.and_then(|s| s.get("auth"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|a| a.get("web"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|w| w.get("allowed_usernames"))
|
||||
.and_then(toml::Value::as_array)
|
||||
.and_then(|allowed| allowed.first())
|
||||
.and_then(|u| u.first())
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("alice")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_server_settings_preserves_existing_api_nested_keys() {
|
||||
let mut doc: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[api]
|
||||
base_url = "https://example.com/api/v1"
|
||||
|
||||
[api.extra]
|
||||
mode = "keep-me"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_server_settings(&mut doc, "alice").unwrap();
|
||||
|
||||
let api = doc.get("api").and_then(toml::Value::as_table).unwrap();
|
||||
let extra = api.get("extra").and_then(toml::Value::as_table).unwrap();
|
||||
assert_eq!(
|
||||
extra.get("mode").and_then(toml::Value::as_str),
|
||||
Some("keep-me")
|
||||
);
|
||||
}
|
||||
|
||||
// -- GitHub App owner --
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ use crate::shared::github::build_github_app_credentials;
|
|||
|
||||
pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> {
|
||||
let ctx = CommandContext::base()?;
|
||||
let github_app = build_github_app_credentials(ctx.machine_settings().app_id())?;
|
||||
let github_app =
|
||||
build_github_app_credentials(ctx.machine_settings().github_app_id_str().as_deref())?;
|
||||
match ns.command {
|
||||
PrCommand::Create(args) => {
|
||||
Box::pin(create::create_command(args, github_app, globals)).await
|
||||
|
|
|
|||
|
|
@ -40,16 +40,13 @@ pub(crate) async fn run_init(args: &RepoInitArgs, globals: &GlobalArgs) -> Resul
|
|||
# Fabro project configuration
|
||||
# https://docs.fabro.computer/getting-started/quick-start
|
||||
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[fabro]
|
||||
root = \"fabro/\"
|
||||
|
||||
# Disable retrospective analysis after workflow runs:
|
||||
# retro = false
|
||||
[project]
|
||||
directory = \"fabro/\"
|
||||
|
||||
# Auto-create pull requests on successful workflow runs.
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = true
|
||||
# auto_merge = true
|
||||
|
|
@ -101,7 +98,7 @@ draft = true
|
|||
let toml_path = workflow_dir.join("workflow.toml");
|
||||
std::fs::write(
|
||||
&toml_path,
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n\n[sandbox]\nprovider = \"local\"\n",
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
created.push("fabro/workflows/hello/workflow.toml".to_string());
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::command_context::CommandContext;
|
|||
use fabro_config::ConfigLayer;
|
||||
use fabro_config::Storage;
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary};
|
||||
|
|
@ -32,11 +33,11 @@ pub(crate) async fn create_run(
|
|||
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
|
||||
let cli_args_config = ConfigLayer::try_from(args)?;
|
||||
let cwd = ctx.cwd().to_path_buf();
|
||||
let _settings = cli_args_config
|
||||
let _settings: SettingsFile = cli_args_config
|
||||
.clone()
|
||||
.combine(ConfigLayer::for_workflow(workflow_path, &cwd)?)
|
||||
.combine(cli_defaults)
|
||||
.resolve()?;
|
||||
.into();
|
||||
let run_id = args
|
||||
.run_id
|
||||
.as_deref()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_config::run::LlmConfig;
|
||||
use fabro_config::{ConfigLayer, sandbox as sandbox_config};
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_config::ConfigLayer;
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer,
|
||||
RunSandboxLayer,
|
||||
};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
|
|
@ -19,44 +26,129 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option<RunModelLayer> {
|
||||
if model.is_none() && provider.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunModelLayer {
|
||||
provider: provider.map(InterpString::parse),
|
||||
name: model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_layer(
|
||||
sandbox: Option<SandboxProvider>,
|
||||
preserve: Option<bool>,
|
||||
) -> Option<RunSandboxLayer> {
|
||||
if sandbox.is_none() && preserve.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunSandboxLayer {
|
||||
provider: sandbox.map(|p| p.to_string()),
|
||||
preserve,
|
||||
..RunSandboxLayer::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn execution_layer(
|
||||
dry_run: Option<bool>,
|
||||
auto_approve: Option<bool>,
|
||||
no_retro: Option<bool>,
|
||||
) -> Option<RunExecutionLayer> {
|
||||
if dry_run.is_none() && auto_approve.is_none() && no_retro.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunExecutionLayer {
|
||||
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
retros: no_retro.map(|nr| !nr),
|
||||
})
|
||||
}
|
||||
|
||||
fn cli_layer_for_verbose(verbose: bool) -> Option<CliLayer> {
|
||||
verbose.then(|| CliLayer {
|
||||
output: Some(CliOutputLayer {
|
||||
verbosity: Some(OutputVerbosity::Verbose),
|
||||
..CliOutputLayer::default()
|
||||
}),
|
||||
..CliLayer::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the `run.goal` override from the `--goal` / `--goal-file` args.
|
||||
///
|
||||
/// The two are mutually exclusive at the clap level; this helper assumes
|
||||
/// at most one is set and returns an error if that invariant is violated.
|
||||
///
|
||||
/// CLI-supplied file paths are anchored at `cwd` (where the user invoked
|
||||
/// the command), matching standard Unix CLI-flag conventions.
|
||||
fn goal_layer_from_args(
|
||||
goal: Option<&str>,
|
||||
goal_file: Option<&Path>,
|
||||
cwd: &Path,
|
||||
) -> Result<Option<RunGoalLayer>> {
|
||||
match (goal, goal_file) {
|
||||
(Some(_), Some(_)) => Err(anyhow!(
|
||||
"--goal and --goal-file are mutually exclusive; use exactly one"
|
||||
)),
|
||||
(Some(text), None) => Ok(Some(RunGoalLayer::Inline(InterpString::parse(text)))),
|
||||
(None, Some(path)) => {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
};
|
||||
Ok(Some(RunGoalLayer::File {
|
||||
file: InterpString::parse(&absolute.to_string_lossy()),
|
||||
}))
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_dir_or_dot() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
impl TryFrom<&RunArgs> for ConfigLayer {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
|
||||
let llm = if args.model.is_some() || args.provider.is_some() {
|
||||
Some(LlmConfig {
|
||||
model: args.model.clone(),
|
||||
provider: args.provider.clone(),
|
||||
fallbacks: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sandbox = if args.sandbox.is_some() || args.preserve_sandbox {
|
||||
Some(sandbox_config::SandboxConfig {
|
||||
provider: args
|
||||
.sandbox
|
||||
.map(Into::into)
|
||||
.map(|provider: SandboxProvider| provider.to_string()),
|
||||
preserve: sparse_flag(args.preserve_sandbox),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = sandbox_layer(
|
||||
args.sandbox.map(Into::into),
|
||||
sparse_flag(args.preserve_sandbox),
|
||||
);
|
||||
let execution = execution_layer(
|
||||
sparse_flag(args.dry_run),
|
||||
sparse_flag(args.auto_approve),
|
||||
sparse_flag(args.no_retro),
|
||||
);
|
||||
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
metadata: parse_labels(&args.label),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
goal: args.goal.clone(),
|
||||
goal_file: args.goal_file.clone(),
|
||||
llm,
|
||||
sandbox,
|
||||
verbose: sparse_flag(args.verbose),
|
||||
dry_run: sparse_flag(args.dry_run),
|
||||
auto_approve: sparse_flag(args.auto_approve),
|
||||
no_retro: sparse_flag(args.no_retro),
|
||||
labels: parse_labels(&args.label),
|
||||
..Default::default()
|
||||
})
|
||||
Ok(Self::from(SettingsFile {
|
||||
run: Some(run),
|
||||
cli: cli_layer_for_verbose(args.verbose),
|
||||
..SettingsFile::default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,27 +156,82 @@ impl TryFrom<&PreflightArgs> for ConfigLayer {
|
|||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {
|
||||
let llm = if args.model.is_some() || args.provider.is_some() {
|
||||
Some(LlmConfig {
|
||||
model: args.model.clone(),
|
||||
provider: args.provider.clone(),
|
||||
fallbacks: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig {
|
||||
provider: Some(SandboxProvider::from(sandbox).to_string()),
|
||||
..Default::default()
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
|
||||
provider: Some(SandboxProvider::from(s).to_string()),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
goal: args.goal.clone(),
|
||||
goal_file: args.goal_file.clone(),
|
||||
llm,
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
model,
|
||||
sandbox,
|
||||
verbose: sparse_flag(args.verbose),
|
||||
..Default::default()
|
||||
})
|
||||
..RunLayer::default()
|
||||
};
|
||||
|
||||
Ok(Self::from(SettingsFile {
|
||||
run: Some(run),
|
||||
cli: cli_layer_for_verbose(args.verbose),
|
||||
..SettingsFile::default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goal_and_goal_file_together_is_rejected() {
|
||||
let err = goal_layer_from_args(
|
||||
Some("inline text"),
|
||||
Some(Path::new("goal.md")),
|
||||
Path::new("/tmp"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("mutually exclusive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_file_is_anchored_at_cwd_when_relative() {
|
||||
let layer =
|
||||
goal_layer_from_args(None, Some(Path::new("prompts/goal.md")), Path::new("/cwd"))
|
||||
.unwrap()
|
||||
.expect("should build a goal layer");
|
||||
let RunGoalLayer::File { file } = layer else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), "/cwd/prompts/goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_goal_file_is_preserved() {
|
||||
let layer = goal_layer_from_args(None, Some(Path::new("/abs/goal.md")), Path::new("/cwd"))
|
||||
.unwrap()
|
||||
.expect("should build a goal layer");
|
||||
let RunGoalLayer::File { file } = layer else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), "/abs/goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_goal_builds_inline_variant() {
|
||||
let layer = goal_layer_from_args(Some("inline goal"), None, Path::new("/cwd"))
|
||||
.unwrap()
|
||||
.expect("should build a goal layer");
|
||||
assert!(matches!(layer, RunGoalLayer::Inline(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_args_produce_no_goal_layer() {
|
||||
assert!(
|
||||
goal_layer_from_args(None, None, Path::new("/cwd"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use anyhow::{Context, Result, anyhow};
|
|||
use async_trait::async_trait;
|
||||
use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage};
|
||||
use fabro_store::{EventEnvelope, EventPayload, RunProjection};
|
||||
use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, Settings, StatusReason};
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason};
|
||||
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
|
||||
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
|
||||
use fabro_workflow::event::{Emitter, RunEventSink};
|
||||
|
|
@ -418,20 +419,19 @@ fn update_worker_title_from_event(event: &RunEvent) {
|
|||
}
|
||||
|
||||
fn maybe_build_github_app_credentials(
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
) -> Result<Option<fabro_github::GitHubAppCredentials>> {
|
||||
let needs_github_app = settings
|
||||
.sandbox_settings()
|
||||
.run_sandbox()
|
||||
.and_then(|sandbox| sandbox.provider.as_deref())
|
||||
.is_some_and(|provider| provider == "daytona")
|
||||
|| settings
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.is_some_and(|pull_request| pull_request.enabled)
|
||||
.run_pull_request()
|
||||
.is_some_and(|pr| pr.enabled.unwrap_or(false))
|
||||
|| settings.github_permissions().is_some();
|
||||
|
||||
if needs_github_app {
|
||||
build_github_app_credentials(settings.app_id())
|
||||
build_github_app_credentials(settings.github_app_id_str().as_deref())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use chrono::Utc;
|
|||
use fabro_config::Storage;
|
||||
use fabro_config::user::default_socket_path;
|
||||
use fabro_server::bind::{Bind, BindRequest};
|
||||
use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV;
|
||||
use fabro_server::serve;
|
||||
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
|
@ -230,7 +231,7 @@ fn execute_daemon(
|
|||
|
||||
cmd.arg("--storage-dir").arg(storage_dir);
|
||||
if matches!(bind, BindRequest::Unix(_)) {
|
||||
cmd.env("FABRO_LOCAL_NO_AUTH", "1");
|
||||
cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1");
|
||||
}
|
||||
|
||||
cmd.env_remove("FABRO_JSON");
|
||||
|
|
|
|||
|
|
@ -297,10 +297,11 @@ mod tests {
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_store::{Database, EventEnvelope, EventPayload};
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::{
|
||||
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
|
||||
NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord,
|
||||
Settings, StageStatus, StartRecord, StatusReason, fixtures,
|
||||
StageStatus, StartRecord, StatusReason, fixtures,
|
||||
};
|
||||
use fabro_workflow::event::{Event, append_event};
|
||||
use object_store::{ObjectStore, memory::InMemory};
|
||||
|
|
@ -334,7 +335,7 @@ mod tests {
|
|||
);
|
||||
RunRecord {
|
||||
run_id,
|
||||
settings: Settings::default(),
|
||||
settings: SettingsFile::default(),
|
||||
graph,
|
||||
workflow_slug: Some("night-sky".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/night-sky"),
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn write_workflow_scaffold(
|
|||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
|
||||
let toml_path = workflows_dir.join("workflow.toml");
|
||||
std::fs::write(&toml_path, "version = 1\n")
|
||||
std::fs::write(&toml_path, "_version = 1\n")
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(vec![dot_path, toml_path])
|
||||
|
|
|
|||
|
|
@ -131,19 +131,27 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}),
|
||||
}) = command.as_ref()
|
||||
{
|
||||
match load_settings_config(args.config.as_deref())
|
||||
.and_then(fabro_types::Settings::try_from)
|
||||
{
|
||||
Ok(server_settings) => (
|
||||
server_settings.log.as_ref().and_then(|l| l.level.clone()),
|
||||
false,
|
||||
),
|
||||
match load_settings_config(args.config.as_deref()) {
|
||||
Ok(layer) => {
|
||||
use fabro_types::settings::SettingsFile;
|
||||
let server_settings: SettingsFile = layer.into();
|
||||
(
|
||||
server_settings
|
||||
.server_logging()
|
||||
.and_then(|l| l.level.clone()),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(err) => return (command_name, Err(err)),
|
||||
}
|
||||
} else {
|
||||
match user_config::load_settings() {
|
||||
Ok(cli_settings) => (
|
||||
cli_settings.log.as_ref().and_then(|l| l.level.clone()),
|
||||
cli_settings
|
||||
.cli
|
||||
.as_ref()
|
||||
.and_then(|c| c.logging.as_ref())
|
||||
.and_then(|l| l.level.clone()),
|
||||
cli_settings.upgrade_check_enabled(),
|
||||
),
|
||||
Err(err) => return (command_name, Err(err)),
|
||||
|
|
@ -196,7 +204,7 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals).await?,
|
||||
Commands::Model { command } => commands::model::execute(command, &globals).await?,
|
||||
Commands::Server(ns) => {
|
||||
commands::server::dispatch(ns.command, &globals).await?;
|
||||
Box::pin(commands::server::dispatch(ns.command, &globals)).await?;
|
||||
}
|
||||
Commands::Doctor(args) => {
|
||||
let cli_settings = user_config::load_settings()?;
|
||||
|
|
@ -231,7 +239,9 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?,
|
||||
Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?,
|
||||
Commands::Settings(args) => commands::config::execute(&args, &globals).await?,
|
||||
Commands::Settings(args) => {
|
||||
Box::pin(commands::config::execute(&args, &globals)).await?;
|
||||
}
|
||||
Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?,
|
||||
Commands::Upgrade(args) => {
|
||||
commands::upgrade::run_upgrade(args, &globals).await?;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ use fabro_api::types;
|
|||
use fabro_config::ConfigLayer;
|
||||
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
|
||||
use fabro_config::run::parse_run_config;
|
||||
use fabro_config::sandbox::DockerfileSource;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_types::{RunId, Settings};
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal};
|
||||
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
|
@ -46,12 +47,12 @@ struct WorkflowScanInput {
|
|||
|
||||
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
||||
let user_layer = ConfigLayer::settings()?;
|
||||
let merged_settings = input
|
||||
let merged_settings: SettingsFile = input
|
||||
.args_layer
|
||||
.clone()
|
||||
.combine(ConfigLayer::for_workflow(&input.workflow, &input.cwd)?)
|
||||
.combine(user_layer.clone())
|
||||
.resolve()?;
|
||||
.into();
|
||||
|
||||
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
|
||||
let target_path = root_resolution.dot_path.clone();
|
||||
|
|
@ -320,13 +321,15 @@ fn collect_workflow_config_files(
|
|||
) -> Result<()> {
|
||||
let config_layer = parse_run_config(&config.source)?;
|
||||
let dockerfile = config_layer
|
||||
.sandbox
|
||||
.as_v2()
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.sandbox.as_ref())
|
||||
.and_then(|sandbox| sandbox.daytona.as_ref())
|
||||
.and_then(|daytona| daytona.snapshot.as_ref())
|
||||
.and_then(|snapshot| snapshot.dockerfile.as_ref());
|
||||
|
||||
let Some(DockerfileSource::Path { path }) = dockerfile else {
|
||||
let Some(DaytonaDockerfileLayer::Path { path }) = dockerfile else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
|
|
@ -383,44 +386,35 @@ fn collect_bundled_file(
|
|||
|
||||
fn resolve_manifest_goal(
|
||||
args_layer: &ConfigLayer,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
root_source: &str,
|
||||
root_dot_path: &Path,
|
||||
cwd: &Path,
|
||||
) -> Result<Option<types::ManifestGoal>> {
|
||||
let working_directory = project::resolve_working_directory(settings, cwd);
|
||||
|
||||
if let Some(goal) = args_layer.goal.as_ref() {
|
||||
return Ok(Some(types::ManifestGoal {
|
||||
path: None,
|
||||
text: goal.clone(),
|
||||
type_: types::ManifestGoalType::Value,
|
||||
}));
|
||||
}
|
||||
if let Some(goal_file) = args_layer.goal_file.as_ref() {
|
||||
return Ok(Some(types::ManifestGoal {
|
||||
path: Some(goal_file.display().to_string()),
|
||||
text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory))
|
||||
.with_context(|| format!("Failed to read {}", goal_file.display()))?,
|
||||
type_: types::ManifestGoalType::File,
|
||||
}));
|
||||
}
|
||||
if let Some(goal) = settings.goal.as_ref() {
|
||||
return Ok(Some(types::ManifestGoal {
|
||||
path: None,
|
||||
text: goal.clone(),
|
||||
type_: types::ManifestGoalType::Value,
|
||||
}));
|
||||
}
|
||||
if let Some(goal_file) = settings.goal_file.as_ref() {
|
||||
return Ok(Some(types::ManifestGoal {
|
||||
path: Some(goal_file.display().to_string()),
|
||||
text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory))
|
||||
.with_context(|| format!("Failed to read {}", goal_file.display()))?,
|
||||
type_: types::ManifestGoalType::File,
|
||||
}));
|
||||
// Precedence 1: CLI args (`--goal` / `--goal-file`). These are already
|
||||
// resolved to absolute paths by `overrides::goal_layer_from_args`.
|
||||
if let Some(resolved) = args_layer
|
||||
.as_v2()
|
||||
.resolve_run_goal(&working_directory)
|
||||
.context("failed to resolve --goal-file contents")?
|
||||
{
|
||||
return Ok(Some(resolved_goal_to_manifest(resolved)));
|
||||
}
|
||||
|
||||
// Precedence 2: merged config `run.goal`. Config-sourced `goal.file`
|
||||
// paths were rewritten to absolute by `ConfigLayer::load` at the
|
||||
// directory of the config file that declared them.
|
||||
if let Some(resolved) = settings
|
||||
.resolve_run_goal(&working_directory)
|
||||
.context("failed to resolve run.goal.file contents")?
|
||||
{
|
||||
return Ok(Some(resolved_goal_to_manifest(resolved)));
|
||||
}
|
||||
|
||||
// Precedence 3: graph-level `goal` attribute in the DOT, with `@file`
|
||||
// sugar for workflow-colocated goal files.
|
||||
let graph = parser::parse(root_source)
|
||||
.map_err(|err| anyhow!("Failed to parse {}: {err}", root_dot_path.display()))?;
|
||||
let Some(goal) = graph.attrs.get("goal").and_then(AttrValue::as_str) else {
|
||||
|
|
@ -447,11 +441,21 @@ fn resolve_manifest_goal(
|
|||
}))
|
||||
}
|
||||
|
||||
fn resolve_goal_file_path(goal_file: &Path, working_directory: &Path) -> PathBuf {
|
||||
if goal_file.is_absolute() {
|
||||
goal_file.to_path_buf()
|
||||
} else {
|
||||
working_directory.join(goal_file)
|
||||
/// Translate a [`ResolvedRunGoal`] into the wire-level `ManifestGoal`
|
||||
/// shape. Inline goals get `type = Value`; file-sourced goals keep their
|
||||
/// absolute path as the `path` field and use `type = File`.
|
||||
fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
|
||||
match resolved.source {
|
||||
ResolvedGoalSource::Inline => types::ManifestGoal {
|
||||
path: None,
|
||||
text: resolved.text,
|
||||
type_: types::ManifestGoalType::Value,
|
||||
},
|
||||
ResolvedGoalSource::File { path } => types::ManifestGoal {
|
||||
path: Some(path.to_string_lossy().into_owned()),
|
||||
text: resolved.text,
|
||||
type_: types::ManifestGoalType::File,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +559,7 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[allow(unsafe_code, clippy::allow_attributes)]
|
||||
fn build_manifest_bundles_imports_prompts_and_children() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path();
|
||||
|
|
@ -563,10 +568,10 @@ mod tests {
|
|||
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
|
||||
std::fs::create_dir_all(workflow_dir.join("imports")).unwrap();
|
||||
std::fs::create_dir_all(&child_dir).unwrap();
|
||||
std::fs::write(project.join("fabro.toml"), "version = 1\n").unwrap();
|
||||
std::fs::write(project.join("fabro.toml"), "_version = 1\n").unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
|
|
@ -601,6 +606,16 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
// Isolate from the developer's real ~/.fabro/settings.toml which may
|
||||
// still be in the legacy shape. Setting FABRO_CONFIG to a path inside
|
||||
// the test tempdir forces the loader to produce an empty ConfigLayer.
|
||||
let sandboxed_settings = temp.path().join("empty-settings.toml");
|
||||
std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap();
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::set_var("FABRO_CONFIG", &sandboxed_settings);
|
||||
}
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
|
||||
cwd: project.to_path_buf(),
|
||||
|
|
@ -610,6 +625,11 @@ mod tests {
|
|||
})
|
||||
.unwrap();
|
||||
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::remove_var("FABRO_CONFIG");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
built.manifest.target.path,
|
||||
"fabro/workflows/demo/workflow.fabro"
|
||||
|
|
@ -640,4 +660,132 @@ mod tests {
|
|||
.contains_key("fabro/workflows/child/workflow.fabro")
|
||||
);
|
||||
}
|
||||
|
||||
/// A relative `[run.goal] file = "..."` declared in `fabro.toml` must
|
||||
/// resolve against the directory of `fabro.toml`, not against the
|
||||
/// invocation cwd. We exercise this by invoking from a subdirectory
|
||||
/// below the project root.
|
||||
#[test]
|
||||
#[allow(unsafe_code, clippy::allow_attributes)]
|
||||
fn build_manifest_resolves_relative_goal_file_in_project_config() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path();
|
||||
let workflow_dir = project.join("fabro/workflows/demo");
|
||||
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||||
std::fs::create_dir_all(project.join("prompts")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
project.join("fabro.toml"),
|
||||
r#"_version = 1
|
||||
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(project.join("prompts/goal.md"), "ship from project root").unwrap();
|
||||
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.fabro"),
|
||||
r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sandboxed_settings = temp.path().join("empty-settings.toml");
|
||||
std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap();
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::set_var("FABRO_CONFIG", &sandboxed_settings);
|
||||
}
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
|
||||
cwd: project.to_path_buf(),
|
||||
args_layer: ConfigLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::remove_var("FABRO_CONFIG");
|
||||
}
|
||||
|
||||
let goal = built.manifest.goal.expect("manifest goal should be set");
|
||||
assert_eq!(goal.text, "ship from project root");
|
||||
assert_eq!(goal.type_, types::ManifestGoalType::File);
|
||||
let resolved = goal.path.expect("file goal must carry a path");
|
||||
let expected = project.join("prompts").join("goal.md");
|
||||
assert_eq!(PathBuf::from(resolved), expected);
|
||||
}
|
||||
|
||||
/// A relative `[run.goal] file = "..."` declared in `workflow.toml`
|
||||
/// must resolve against the directory of `workflow.toml`, not against
|
||||
/// the invocation cwd or project root.
|
||||
#[test]
|
||||
#[allow(unsafe_code, clippy::allow_attributes)]
|
||||
fn build_manifest_resolves_relative_goal_file_in_workflow_config() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path();
|
||||
let workflow_dir = project.join("fabro/workflows/demo");
|
||||
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
|
||||
|
||||
std::fs::write(project.join("fabro.toml"), "_version = 1\n").unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("prompts/goal.md"),
|
||||
"ship from workflow dir",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.fabro"),
|
||||
r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sandboxed_settings = temp.path().join("empty-settings.toml");
|
||||
std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap();
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::set_var("FABRO_CONFIG", &sandboxed_settings);
|
||||
}
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
|
||||
cwd: project.to_path_buf(),
|
||||
args_layer: ConfigLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// SAFETY: single-threaded unit test body.
|
||||
unsafe {
|
||||
std::env::remove_var("FABRO_CONFIG");
|
||||
}
|
||||
|
||||
let goal = built.manifest.goal.expect("manifest goal should be set");
|
||||
assert_eq!(goal.text, "ship from workflow dir");
|
||||
assert_eq!(goal.type_, types::ManifestGoalType::File);
|
||||
let resolved = goal.path.expect("file goal must carry a path");
|
||||
let expected = workflow_dir.join("prompts").join("goal.md");
|
||||
assert_eq!(PathBuf::from(resolved), expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use bytes::Bytes;
|
|||
use fabro_api::types;
|
||||
use fabro_server::bind::Bind;
|
||||
use fabro_store::{EventEnvelope, RunSummary, StageId};
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId, Settings};
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId};
|
||||
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
|
||||
use futures::StreamExt;
|
||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
|
|
@ -99,7 +100,7 @@ pub(crate) async fn connect_server_target_direct(target: &str) -> Result<ServerS
|
|||
|
||||
pub(crate) async fn connect_server_with_settings(
|
||||
args: &ServerTargetArgs,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
base_config_path: &Path,
|
||||
) -> Result<ServerStoreClient> {
|
||||
let target = user_config::resolve_server_target(args, settings)?;
|
||||
|
|
@ -277,14 +278,16 @@ impl ServerStoreClient {
|
|||
&self.base_url
|
||||
}
|
||||
|
||||
pub(crate) async fn retrieve_server_settings(&self) -> Result<Settings> {
|
||||
pub(crate) async fn retrieve_server_settings(&self) -> Result<SettingsFile> {
|
||||
let response = self
|
||||
.client
|
||||
.retrieve_server_settings()
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
convert_type(response.into_inner())
|
||||
let raw = serde_json::Value::Object(response.into_inner().into());
|
||||
serde_json::from_value::<SettingsFile>(raw)
|
||||
.context("server returned a settings payload that does not match the v2 schema")
|
||||
}
|
||||
|
||||
pub(crate) async fn create_run_from_manifest(
|
||||
|
|
|
|||
|
|
@ -4,13 +4,22 @@ pub(crate) use fabro_config::user::*;
|
|||
|
||||
use anyhow::{Result, bail};
|
||||
use fabro_config::ConfigLayer;
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
/// Client-side TLS material for the CLI's remote server target.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub(crate) struct ClientTlsSettings {
|
||||
pub cert: PathBuf,
|
||||
pub key: PathBuf,
|
||||
pub ca: PathBuf,
|
||||
}
|
||||
|
||||
use crate::args::ServerTargetArgs;
|
||||
|
||||
pub(crate) fn load_settings() -> anyhow::Result<Settings> {
|
||||
pub(crate) fn load_settings() -> anyhow::Result<SettingsFile> {
|
||||
load_settings_with_config_and_storage_dir(None, None)
|
||||
}
|
||||
|
||||
|
|
@ -30,23 +39,30 @@ pub(crate) fn settings_layer_with_storage_dir(
|
|||
|
||||
pub(crate) fn load_settings_with_storage_dir(
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<Settings> {
|
||||
settings_layer_with_storage_dir(storage_dir)?.resolve()
|
||||
) -> anyhow::Result<SettingsFile> {
|
||||
Ok(settings_layer_with_storage_dir(storage_dir)?.into())
|
||||
}
|
||||
|
||||
pub(crate) fn load_settings_with_config_and_storage_dir(
|
||||
config_path: Option<&Path>,
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<Settings> {
|
||||
settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve()
|
||||
) -> anyhow::Result<SettingsFile> {
|
||||
Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.into())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_storage_dir_override(
|
||||
mut layer: ConfigLayer,
|
||||
storage_dir: Option<&Path>,
|
||||
) -> ConfigLayer {
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::server::{ServerLayer, ServerStorageLayer};
|
||||
if let Some(dir) = storage_dir {
|
||||
layer.storage_dir = Some(dir.to_path_buf());
|
||||
let file = layer.as_v2_mut();
|
||||
let server = file.server.get_or_insert_with(ServerLayer::default);
|
||||
let storage = server
|
||||
.storage
|
||||
.get_or_insert_with(ServerStorageLayer::default);
|
||||
storage.root = Some(InterpString::parse(&dir.display().to_string()));
|
||||
}
|
||||
|
||||
layer
|
||||
|
|
@ -61,21 +77,39 @@ pub(crate) enum ServerTarget {
|
|||
UnixSocket(PathBuf),
|
||||
}
|
||||
|
||||
fn configured_server_target(settings: &Settings) -> Result<Option<ServerTarget>> {
|
||||
settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.target.as_deref())
|
||||
.map(|value| {
|
||||
parse_server_target(
|
||||
value,
|
||||
settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.tls.clone()),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
/// Pull the CLI target configuration out of the v2 `[cli.target]` stanza.
|
||||
/// Returns `(target_string, tls)` where `target_string` is either an
|
||||
/// http(s) URL or a unix socket path. `tls` is the CLI-side client TLS
|
||||
/// settings extracted from `[cli.target.http.tls]`.
|
||||
fn cli_target_from_v2(settings: &SettingsFile) -> Option<(String, Option<ClientTlsSettings>)> {
|
||||
use fabro_types::settings::cli::CliTargetLayer;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
|
||||
let target = settings.cli.as_ref()?.target.as_ref()?;
|
||||
match target {
|
||||
CliTargetLayer::Http { url, tls } => {
|
||||
let url_str = url.as_ref().map(InterpString::as_source)?;
|
||||
let tls_settings = tls.as_ref().and_then(|tls| {
|
||||
Some(ClientTlsSettings {
|
||||
cert: PathBuf::from(tls.cert.as_ref().map(InterpString::as_source)?),
|
||||
key: PathBuf::from(tls.key.as_ref().map(InterpString::as_source)?),
|
||||
ca: PathBuf::from(tls.ca.as_ref().map(InterpString::as_source)?),
|
||||
})
|
||||
});
|
||||
Some((url_str, tls_settings))
|
||||
}
|
||||
CliTargetLayer::Unix { path } => path
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.map(|path_str| (path_str, None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_server_target(settings: &SettingsFile) -> Result<Option<ServerTarget>> {
|
||||
let Some((value, tls)) = cli_target_from_v2(settings) else {
|
||||
return Ok(None);
|
||||
};
|
||||
parse_server_target(&value, tls).map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn default_server_target() -> ServerTarget {
|
||||
|
|
@ -100,24 +134,18 @@ fn parse_server_target(value: &str, tls: Option<ClientTlsSettings>) -> Result<Se
|
|||
|
||||
fn explicit_server_target(
|
||||
args: &ServerTargetArgs,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
) -> Result<Option<ServerTarget>> {
|
||||
args.as_deref()
|
||||
.map(|value| {
|
||||
parse_server_target(
|
||||
value,
|
||||
settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.tls.clone()),
|
||||
)
|
||||
parse_server_target(value, cli_target_from_v2(settings).and_then(|(_, tls)| tls))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_server_target(
|
||||
args: &ServerTargetArgs,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
) -> Result<ServerTarget> {
|
||||
explicit_server_target(args, settings)?
|
||||
.or(configured_server_target(settings)?)
|
||||
|
|
@ -126,7 +154,7 @@ pub(crate) fn resolve_server_target(
|
|||
|
||||
pub(crate) fn exec_server_target(
|
||||
args: &ServerTargetArgs,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
) -> Result<Option<ServerTarget>> {
|
||||
let target = explicit_server_target(args, settings)?;
|
||||
debug!(?target, "Resolved exec server target");
|
||||
|
|
@ -179,9 +207,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_v2(source: &str) -> SettingsFile {
|
||||
fabro_config::ConfigLayer::parse(source)
|
||||
.expect("fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_has_no_server_target_by_default() {
|
||||
let settings = Settings::default();
|
||||
let settings = SettingsFile::default();
|
||||
assert_eq!(
|
||||
exec_server_target(&server_target_args(None), &settings).unwrap(),
|
||||
None
|
||||
|
|
@ -190,7 +224,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn exec_uses_cli_server_target() {
|
||||
let settings = Settings::default();
|
||||
let settings = SettingsFile::default();
|
||||
assert_eq!(
|
||||
exec_server_target(
|
||||
&server_target_args(Some("https://cli.example.com")),
|
||||
|
|
@ -206,7 +240,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn exec_supports_explicit_unix_socket_target() {
|
||||
let settings = Settings::default();
|
||||
let settings = SettingsFile::default();
|
||||
assert_eq!(
|
||||
exec_server_target(&server_target_args(Some("/tmp/fabro.sock")), &settings).unwrap(),
|
||||
Some(ServerTarget::UnixSocket(PathBuf::from("/tmp/fabro.sock")))
|
||||
|
|
@ -215,13 +249,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn exec_ignores_configured_server_target_without_cli_override() {
|
||||
let settings = Settings {
|
||||
server: Some(ServerSettings {
|
||||
target: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..Settings::default()
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://config.example.com"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
exec_server_target(&server_target_args(None), &settings).unwrap(),
|
||||
None
|
||||
|
|
@ -230,13 +266,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn resolve_server_target_uses_configured_server_target() {
|
||||
let settings = Settings {
|
||||
server: Some(ServerSettings {
|
||||
target: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..Settings::default()
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://config.example.com"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
||||
ServerTarget::HttpUrl {
|
||||
|
|
@ -248,13 +286,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn resolve_server_target_explicit_target_overrides_config_target() {
|
||||
let settings = Settings {
|
||||
server: Some(ServerSettings {
|
||||
target: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..Settings::default()
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://config.example.com"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_target(
|
||||
&server_target_args(Some("https://cli.example.com")),
|
||||
|
|
@ -270,7 +310,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn resolve_server_target_defaults_to_default_unix_socket_target() {
|
||||
let settings = Settings::default();
|
||||
let settings = SettingsFile::default();
|
||||
assert_eq!(
|
||||
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
||||
ServerTarget::UnixSocket(dirs::home_dir().unwrap().join(".fabro/fabro.sock"))
|
||||
|
|
@ -279,13 +319,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn explicit_server_target_overrides_config_target() {
|
||||
let settings = Settings {
|
||||
server: Some(ServerSettings {
|
||||
target: Some("https://config.example.com".to_string()),
|
||||
tls: None,
|
||||
}),
|
||||
..Settings::default()
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://config.example.com"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_target(
|
||||
&server_target_args(Some("https://cli.example.com")),
|
||||
|
|
@ -301,18 +343,25 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn remote_target_uses_tls_from_config() {
|
||||
let tls = ClientTlsSettings {
|
||||
let expected_tls = ClientTlsSettings {
|
||||
cert: PathBuf::from("cert.pem"),
|
||||
key: PathBuf::from("key.pem"),
|
||||
ca: PathBuf::from("ca.pem"),
|
||||
};
|
||||
let settings = Settings {
|
||||
server: Some(ServerSettings {
|
||||
target: None,
|
||||
tls: Some(tls.clone()),
|
||||
}),
|
||||
..Settings::default()
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "https://config.example.com"
|
||||
|
||||
[cli.target.tls]
|
||||
cert = "cert.pem"
|
||||
key = "key.pem"
|
||||
ca = "ca.pem"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
exec_server_target(
|
||||
&server_target_args(Some("https://cli.example.com")),
|
||||
|
|
@ -321,14 +370,14 @@ mod tests {
|
|||
.unwrap(),
|
||||
Some(ServerTarget::HttpUrl {
|
||||
api_url: "https://cli.example.com".to_string(),
|
||||
tls: Some(tls),
|
||||
tls: Some(expected_tls),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_server_target_is_rejected() {
|
||||
let settings = Settings::default();
|
||||
let settings = SettingsFile::default();
|
||||
let error =
|
||||
exec_server_target(&server_target_args(Some("fabro.internal")), &settings).unwrap_err();
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -421,6 +421,27 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Strip v2-shape server/version fields that the bridge emits,
|
||||
// since the test fixture's socket path is randomised per run.
|
||||
if let Some(settings) = event
|
||||
.pointer_mut("/properties/settings")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
settings.remove("_version");
|
||||
settings.remove("server");
|
||||
settings.remove("version");
|
||||
}
|
||||
if let Some(target) = event
|
||||
.pointer_mut("/properties/settings/cli/target")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
if target.contains_key("path") {
|
||||
target.insert(
|
||||
"path".to_string(),
|
||||
Value::String("[CLI_SOCKET]".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
event
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -547,22 +568,25 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
},
|
||||
"run_dir": "[RUN_DIR]",
|
||||
"settings": {
|
||||
"goal": "Wait for approval",
|
||||
"llm": {
|
||||
"fallbacks": null,
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai"
|
||||
"run": {
|
||||
"execution": {
|
||||
"retros": false
|
||||
},
|
||||
"goal": "Wait for approval",
|
||||
"model": {
|
||||
"name": "gpt-5.4",
|
||||
"provider": "openai"
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": "local"
|
||||
}
|
||||
},
|
||||
"no_retro": true,
|
||||
"sandbox": {
|
||||
"daytona": null,
|
||||
"devcontainer": null,
|
||||
"env": null,
|
||||
"local": null,
|
||||
"preserve": null,
|
||||
"provider": "local"
|
||||
},
|
||||
"storage_dir": "[STORAGE_DIR]"
|
||||
"cli": {
|
||||
"target": {
|
||||
"path": "[CLI_SOCKET]",
|
||||
"type": "unix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workflow_slug": "human-gate",
|
||||
"workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use fabro_config::mcp::McpTransport;
|
||||
use fabro_config::ConfigLayer;
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use httpmock::MockServer;
|
||||
use predicates::prelude::*;
|
||||
|
||||
|
|
@ -31,49 +31,33 @@ fn old_config_show_command_is_rejected() {
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_settings(stdout: &[u8]) -> Settings {
|
||||
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML Settings")
|
||||
fn parse_settings(stdout: &[u8]) -> SettingsFile {
|
||||
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile")
|
||||
}
|
||||
|
||||
fn server_settings_fixture() -> Settings {
|
||||
toml::from_str(
|
||||
fn server_settings_fixture() -> SettingsFile {
|
||||
ConfigLayer::parse(
|
||||
r#"
|
||||
storage_dir = "/srv/fabro-server"
|
||||
verbose = false
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "server-model"
|
||||
[server.storage]
|
||||
root = "/srv/fabro-server"
|
||||
|
||||
[run.model]
|
||||
name = "server-model"
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
server_only = "1"
|
||||
shared = "server"
|
||||
"#,
|
||||
)
|
||||
.expect("server settings fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
fn server_settings_body(settings: &Settings) -> String {
|
||||
fn strip_nulls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for child in map.values_mut() {
|
||||
strip_nulls(child);
|
||||
}
|
||||
map.retain(|_, child| !child.is_null());
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for child in values {
|
||||
strip_nulls(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut value = serde_json::to_value(settings).expect("settings fixture should serialize");
|
||||
strip_nulls(&mut value);
|
||||
serde_json::to_string(&value).expect("settings payload should serialize")
|
||||
fn server_settings_body(settings: &SettingsFile) -> String {
|
||||
serde_json::to_string(settings).expect("settings payload should serialize")
|
||||
}
|
||||
|
||||
/// Set up home config and project config for settings command tests.
|
||||
|
|
@ -82,37 +66,44 @@ fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDi
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
r#"
|
||||
verbose = true
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "cli-model"
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "cli-model"
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
cli_only = "1"
|
||||
shared = "cli"
|
||||
|
||||
[checkpoint]
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["cli-only", "shared"]
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "shared"
|
||||
name = "shared"
|
||||
event = "run_start"
|
||||
command = "echo cli"
|
||||
script = "echo cli"
|
||||
|
||||
[mcp_servers.shared]
|
||||
[run.agent.mcps.shared]
|
||||
type = "stdio"
|
||||
command = ["echo", "cli"]
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
labels = { cli_only = "1", shared = "cli" }
|
||||
|
||||
[sandbox.env]
|
||||
[run.sandbox.env]
|
||||
CLI_ONLY = "1"
|
||||
SHARED = "cli"
|
||||
|
||||
[run.sandbox.daytona]
|
||||
|
||||
[run.sandbox.daytona.labels]
|
||||
cli_only = "1"
|
||||
shared = "cli"
|
||||
"#,
|
||||
);
|
||||
|
||||
|
|
@ -120,22 +111,23 @@ SHARED = "cli"
|
|||
std::fs::write(
|
||||
project.path().join("fabro.toml"),
|
||||
r#"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[fabro]
|
||||
root = "fabro"
|
||||
[project]
|
||||
directory = "fabro"
|
||||
|
||||
[llm]
|
||||
model = "project-model"
|
||||
[run.model]
|
||||
name = "project-model"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
project_only = "1"
|
||||
shared = "project"
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "project"
|
||||
name = "project"
|
||||
event = "run_complete"
|
||||
command = "echo project"
|
||||
script = "echo project"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -145,44 +137,51 @@ command = "echo project"
|
|||
std::fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
r#"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[run]
|
||||
goal = "demo goal"
|
||||
|
||||
[llm]
|
||||
model = "run-model"
|
||||
[run.model]
|
||||
name = "run-model"
|
||||
provider = "anthropic"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
run_only = "1"
|
||||
shared = "run"
|
||||
|
||||
[checkpoint]
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["run-only", "shared"]
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "shared"
|
||||
name = "shared"
|
||||
event = "run_start"
|
||||
command = "echo run"
|
||||
script = "echo run"
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "run-only"
|
||||
name = "run-only"
|
||||
event = "run_complete"
|
||||
command = "echo run-only"
|
||||
script = "echo run-only"
|
||||
|
||||
[mcp_servers.shared]
|
||||
[run.agent.mcps.shared]
|
||||
type = "stdio"
|
||||
command = ["echo", "run"]
|
||||
|
||||
[mcp_servers.run_only]
|
||||
[run.agent.mcps.run_only]
|
||||
type = "stdio"
|
||||
command = ["echo", "run-only"]
|
||||
|
||||
[sandbox.daytona]
|
||||
labels = { run_only = "1", shared = "run" }
|
||||
|
||||
[sandbox.env]
|
||||
[run.sandbox.env]
|
||||
RUN_ONLY = "1"
|
||||
SHARED = "run"
|
||||
|
||||
[run.sandbox.daytona]
|
||||
|
||||
[run.sandbox.daytona.labels]
|
||||
run_only = "1"
|
||||
shared = "run"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -208,11 +207,16 @@ fn setup_external_workflow_fixture(
|
|||
".fabro/settings.toml",
|
||||
format!(
|
||||
r#"
|
||||
storage_dir = "{}"
|
||||
auto_approve = true
|
||||
_version = 1
|
||||
|
||||
[setup]
|
||||
commands = ["cli-setup"]
|
||||
[server.storage]
|
||||
root = "{}"
|
||||
|
||||
[run.execution]
|
||||
approval = "auto"
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "cli-setup"
|
||||
"#,
|
||||
storage_dir.display()
|
||||
),
|
||||
|
|
@ -222,12 +226,12 @@ commands = ["cli-setup"]
|
|||
std::fs::write(
|
||||
project.path().join("fabro.toml"),
|
||||
r#"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[setup]
|
||||
commands = ["project-setup"]
|
||||
[[run.prepare.steps]]
|
||||
script = "project-setup"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
preserve = true
|
||||
"#,
|
||||
)
|
||||
|
|
@ -248,15 +252,19 @@ digraph Test {
|
|||
std::fs::write(
|
||||
project.path().join("workflow.toml"),
|
||||
r#"
|
||||
version = 1
|
||||
goal = "Ship it"
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[llm]
|
||||
model = "claude-sonnet-4-6"
|
||||
[run]
|
||||
goal = "Ship it"
|
||||
|
||||
[setup]
|
||||
commands = ["workflow-setup"]
|
||||
[run.model]
|
||||
name = "claude-sonnet-4-6"
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "workflow-setup"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -284,23 +292,25 @@ fn settings_local_merges_cli_and_project_defaults() {
|
|||
.clone();
|
||||
|
||||
let cfg = parse_settings(&output);
|
||||
let llm = cfg.llm.as_ref().expect("llm config");
|
||||
assert_eq!(llm.model.as_deref(), Some("project-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(cfg.goal.as_deref(), None);
|
||||
assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro"));
|
||||
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
|
||||
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai"));
|
||||
assert_eq!(cfg.run_goal_inline_str().as_deref(), None);
|
||||
assert_eq!(cfg.project_directory(), Some("fabro"));
|
||||
|
||||
let vars = cfg.vars.as_ref().expect("vars");
|
||||
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("shared").map(String::as_str), Some("project"));
|
||||
// v2 R22: run.inputs replaces the inherited map wholesale rather than
|
||||
// merging by key, so the project layer wipes out the CLI layer's inputs.
|
||||
let vars = cfg.run_inputs().expect("run.inputs");
|
||||
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
|
||||
assert!(
|
||||
!vars.contains_key("cli_only"),
|
||||
"run.inputs should replace across layers, not merge by key"
|
||||
);
|
||||
|
||||
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
|
||||
let labels = sandbox
|
||||
.daytona
|
||||
.as_ref()
|
||||
.and_then(|d| d.labels.as_ref())
|
||||
.expect("daytona labels");
|
||||
// v2 R71: provider-native maps such as run.sandbox.daytona.labels remain
|
||||
// sticky merge-by-key, so CLI labels persist under the project layer.
|
||||
let sandbox = cfg.run_sandbox().expect("run.sandbox");
|
||||
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
|
||||
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(labels.get("shared").map(String::as_str), Some("cli"));
|
||||
}
|
||||
|
|
@ -320,65 +330,80 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
|||
.stdout
|
||||
.clone();
|
||||
|
||||
use fabro_types::settings::run::McpEntryLayer;
|
||||
|
||||
let cfg = parse_settings(&output);
|
||||
let llm = cfg.llm.as_ref().expect("llm config");
|
||||
assert_eq!(cfg.goal.as_deref(), Some("demo goal"));
|
||||
assert_eq!(llm.model.as_deref(), Some("run-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
|
||||
assert_eq!(cfg.run_goal_inline_str().as_deref(), Some("demo goal"));
|
||||
assert_eq!(cfg.run_model_name_str().as_deref(), Some("run-model"));
|
||||
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("anthropic"));
|
||||
|
||||
let vars = cfg.vars.as_ref().expect("vars");
|
||||
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("run_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("shared").map(String::as_str), Some("run"));
|
||||
// v2 R22: run.inputs replaces wholesale, so the workflow layer wins
|
||||
// over project and cli.
|
||||
let vars = cfg.run_inputs().expect("run.inputs");
|
||||
assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1"));
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run"));
|
||||
|
||||
// checkpoint.exclude_globs is a security/policy list: replace by default.
|
||||
let checkpoint = cfg.run_checkpoint().expect("run.checkpoint");
|
||||
assert_eq!(
|
||||
cfg.checkpoint.exclude_globs,
|
||||
vec![
|
||||
"cli-only".to_string(),
|
||||
"run-only".to_string(),
|
||||
"shared".to_string()
|
||||
]
|
||||
checkpoint.exclude_globs,
|
||||
vec!["run-only".to_string(), "shared".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(cfg.hooks.len(), 3);
|
||||
let shared_hook = cfg
|
||||
.hooks
|
||||
// Hooks: id-based replacement. The "shared" hook appears in both cli and
|
||||
// workflow layers and resolves to the workflow entry; project and run-only
|
||||
// contribute the other two ids.
|
||||
let hooks = cfg.run_hooks();
|
||||
assert!(hooks.len() >= 2);
|
||||
let shared_hook = hooks
|
||||
.iter()
|
||||
.find(|hook| hook.name.as_deref() == Some("shared"))
|
||||
.expect("shared hook");
|
||||
assert_eq!(shared_hook.command.as_deref(), Some("echo run"));
|
||||
assert!(
|
||||
cfg.hooks
|
||||
.iter()
|
||||
.any(|hook| hook.name.as_deref() == Some("project"))
|
||||
assert_eq!(
|
||||
shared_hook
|
||||
.script
|
||||
.as_ref()
|
||||
.map(|s| s.as_source())
|
||||
.as_deref(),
|
||||
Some("echo run")
|
||||
);
|
||||
assert!(
|
||||
cfg.hooks
|
||||
hooks
|
||||
.iter()
|
||||
.any(|hook| hook.name.as_deref() == Some("run-only"))
|
||||
);
|
||||
|
||||
match &cfg.mcp_servers["shared"].transport {
|
||||
McpTransport::Stdio { command, .. } => assert_eq!(command, &vec!["echo", "run"]),
|
||||
let mcps = cfg.run_agent_mcps().expect("run.agent.mcps");
|
||||
match mcps.get("shared").expect("shared mcp") {
|
||||
McpEntryLayer::Stdio { command, .. } => {
|
||||
let command = command.as_ref().expect("command");
|
||||
let parts: Vec<String> = command.iter().map(|c| c.as_source()).collect();
|
||||
assert_eq!(parts, vec!["echo".to_string(), "run".to_string()]);
|
||||
}
|
||||
other => panic!("unexpected MCP transport: {other:?}"),
|
||||
}
|
||||
assert!(cfg.mcp_servers.contains_key("run_only"));
|
||||
assert!(mcps.contains_key("run_only"));
|
||||
|
||||
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
|
||||
let labels = sandbox
|
||||
.daytona
|
||||
.as_ref()
|
||||
.and_then(|d| d.labels.as_ref())
|
||||
.expect("daytona labels");
|
||||
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
|
||||
// run.sandbox.daytona.labels stays sticky merge-by-key per R71.
|
||||
let sandbox = cfg.run_sandbox().expect("run.sandbox");
|
||||
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
|
||||
assert_eq!(labels.get("run_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(labels.get("shared").map(String::as_str), Some("run"));
|
||||
|
||||
let env = sandbox.env.as_ref().expect("sandbox env");
|
||||
assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1"));
|
||||
assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1"));
|
||||
assert_eq!(env.get("SHARED").map(String::as_str), Some("run"));
|
||||
// run.sandbox.env stays sticky merge-by-key per R71.
|
||||
let env = &sandbox.env;
|
||||
assert_eq!(
|
||||
env.get("CLI_ONLY").map(|v| v.as_source()).as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("RUN_ONLY").map(|v| v.as_source()).as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("SHARED").map(|v| v.as_source()).as_deref(),
|
||||
Some("run")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -401,19 +426,14 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
|
|||
.clone();
|
||||
|
||||
let cfg = parse_settings(&output);
|
||||
assert_eq!(cfg.auto_approve, Some(true));
|
||||
assert!(cfg.auto_approve_enabled());
|
||||
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
||||
// The highest-precedence layer (workflow) wins.
|
||||
assert_eq!(
|
||||
cfg.setup.as_ref().expect("setup config").commands,
|
||||
vec![
|
||||
"workflow-setup".to_string(),
|
||||
"project-setup".to_string(),
|
||||
"cli-setup".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.sandbox.as_ref().expect("sandbox config").preserve,
|
||||
Some(true)
|
||||
cfg.run_prepare_commands(),
|
||||
vec!["workflow-setup".to_string()]
|
||||
);
|
||||
assert_eq!(cfg.run_sandbox().and_then(|sb| sb.preserve), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -462,22 +482,26 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|||
let state = run_state(&run_dir);
|
||||
let run_record =
|
||||
serde_json::to_value(state.run.as_ref().expect("run record should exist")).unwrap();
|
||||
assert_eq!(run_record["settings"]["auto_approve"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
run_record["settings"]["storage_dir"].as_str(),
|
||||
run_record["settings"]["run"]["execution"]["approval"].as_str(),
|
||||
Some("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["server"]["storage"]["root"].as_str(),
|
||||
Some(storage_dir.to_str().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["sandbox"]["preserve"].as_bool(),
|
||||
run_record["settings"]["run"]["sandbox"]["preserve"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["llm"]["model"].as_str(),
|
||||
run_record["settings"]["run"]["model"]["name"].as_str(),
|
||||
Some("gpt-5.2")
|
||||
);
|
||||
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
||||
assert_eq!(
|
||||
run_record["settings"]["setup"]["commands"],
|
||||
serde_json::json!(["workflow-setup", "project-setup", "cli-setup"])
|
||||
run_record["settings"]["run"]["prepare"]["steps"],
|
||||
serde_json::json!([{"script": "workflow-setup"}])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -538,10 +562,13 @@ fn settings_legacy_cli_config_warns_and_ignores_it() {
|
|||
context.write_home(
|
||||
".fabro/cli.toml",
|
||||
r#"
|
||||
verbose = true
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "legacy-model"
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "legacy-model"
|
||||
"#,
|
||||
);
|
||||
|
||||
|
|
@ -555,8 +582,8 @@ model = "legacy-model"
|
|||
.stderr(predicate::str::contains("Rename it to"));
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(cfg.verbose, None);
|
||||
assert_eq!(cfg.llm, None);
|
||||
assert!(!cfg.verbose_enabled());
|
||||
assert!(cfg.run_model().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -566,10 +593,12 @@ fn settings_user_config_wins_over_legacy_cli_config() {
|
|||
context.write_home(
|
||||
".fabro/cli.toml",
|
||||
r#"
|
||||
[llm]
|
||||
model = "legacy-model"
|
||||
_version = 1
|
||||
|
||||
[vars]
|
||||
[run.model]
|
||||
name = "legacy-model"
|
||||
|
||||
[run.inputs]
|
||||
shared = "legacy"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -583,12 +612,11 @@ shared = "legacy"
|
|||
.stderr(predicate::str::contains("ignoring legacy config file"));
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
let llm = cfg.llm.as_ref().expect("llm config");
|
||||
assert_eq!(llm.model.as_deref(), Some("project-model"));
|
||||
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
|
||||
assert_eq!(
|
||||
cfg.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("shared").map(String::as_str)),
|
||||
cfg.run_inputs()
|
||||
.and_then(|vars| vars.get("shared"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("project")
|
||||
);
|
||||
}
|
||||
|
|
@ -601,10 +629,13 @@ fn settings_uses_fabro_home_for_home_config_resolution() {
|
|||
std::fs::write(
|
||||
fabro_home.path().join("settings.toml"),
|
||||
r#"
|
||||
verbose = true
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "from-fabro-home"
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "from-fabro-home"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -625,8 +656,11 @@ model = "from-fabro-home"
|
|||
);
|
||||
|
||||
let cfg: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(cfg["verbose"].as_bool(), Some(true));
|
||||
assert_eq!(cfg["llm"]["model"].as_str(), Some("from-fabro-home"));
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose"));
|
||||
assert_eq!(
|
||||
cfg["run"]["model"]["name"].as_str(),
|
||||
Some("from-fabro-home")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -684,14 +718,20 @@ fn settings_fetches_server_settings_and_merges_with_local_config() {
|
|||
".fabro/settings.toml",
|
||||
format!(
|
||||
r#"
|
||||
server = {{ target = "{}/api/v1" }}
|
||||
verbose = true
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
model = "cli-model"
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "{}/api/v1"
|
||||
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "cli-model"
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
cli_only = "1"
|
||||
shared = "cli"
|
||||
"#,
|
||||
|
|
@ -710,16 +750,24 @@ shared = "cli"
|
|||
|
||||
mock.assert();
|
||||
let cfg = parse_settings(&output);
|
||||
let llm = cfg.llm.as_ref().expect("llm config");
|
||||
assert_eq!(llm.model.as_deref(), Some("project-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server")));
|
||||
assert_eq!(cfg.verbose, Some(true));
|
||||
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
|
||||
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai"));
|
||||
assert_eq!(
|
||||
cfg.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro-server")
|
||||
);
|
||||
assert!(cfg.verbose_enabled());
|
||||
|
||||
let vars = cfg.vars.as_ref().expect("vars");
|
||||
assert_eq!(vars.get("server_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("shared").map(String::as_str), Some("project"));
|
||||
// R22: run.inputs replaces wholesale across layers. Project is the
|
||||
// highest-precedence layer that sets inputs, so project's vars win
|
||||
// and server-side vars are discarded rather than merged.
|
||||
let vars = cfg.run_inputs().expect("run.inputs");
|
||||
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
|
||||
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
|
||||
assert!(
|
||||
!vars.contains_key("server_only"),
|
||||
"v2 merge matrix replaces run.inputs wholesale; server_only should be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -744,10 +792,14 @@ fn settings_cli_server_target_overrides_configured_server_target() {
|
|||
".fabro/settings.toml",
|
||||
format!(
|
||||
r#"
|
||||
[server]
|
||||
target = "{}/api/v1"
|
||||
_version = 1
|
||||
|
||||
verbose = true
|
||||
[cli.target]
|
||||
type = "http"
|
||||
url = "{}/api/v1"
|
||||
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
"#,
|
||||
configured_server.base_url()
|
||||
),
|
||||
|
|
@ -766,7 +818,10 @@ verbose = true
|
|||
cli_mock.assert();
|
||||
configured_mock.assert_calls(0);
|
||||
let cfg = parse_settings(&output);
|
||||
assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server")));
|
||||
assert_eq!(
|
||||
cfg.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro-server")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ fn create_uses_configured_server_target_without_server_flag() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let output = context
|
||||
|
|
@ -162,7 +165,7 @@ fn create_cli_server_target_overrides_configured_server_target() {
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!(
|
||||
"[server]\ntarget = \"{}/api/v1\"\n",
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
config_server.base_url()
|
||||
),
|
||||
);
|
||||
|
|
@ -349,21 +352,22 @@ fn create_persists_requested_overrides_into_store() {
|
|||
"env": run_record.labels.get("env"),
|
||||
"team": run_record.labels.get("team"),
|
||||
});
|
||||
let settings = &run_record.settings;
|
||||
let compact = json!({
|
||||
"workflow_slug": run_record.workflow_slug,
|
||||
"settings": {
|
||||
"goal": run_record.settings.goal,
|
||||
"dry_run": run_record.settings.dry_run,
|
||||
"auto_approve": run_record.settings.auto_approve,
|
||||
"no_retro": run_record.settings.no_retro,
|
||||
"verbose": run_record.settings.verbose,
|
||||
"goal": settings.run_goal_inline_str(),
|
||||
"dry_run": settings.dry_run_enabled(),
|
||||
"auto_approve": settings.auto_approve_enabled(),
|
||||
"no_retro": settings.no_retro_enabled(),
|
||||
"verbose": settings.verbose_enabled(),
|
||||
"llm": {
|
||||
"model": run_record.settings.llm.as_ref().and_then(|llm| llm.model.clone()),
|
||||
"provider": run_record.settings.llm.as_ref().and_then(|llm| llm.provider.clone()),
|
||||
"model": settings.run_model_name_str(),
|
||||
"provider": settings.run_model_provider_str(),
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": run_record.settings.sandbox.as_ref().and_then(|sandbox| sandbox.provider.clone()),
|
||||
"preserve": run_record.settings.sandbox.as_ref().and_then(|sandbox| sandbox.preserve),
|
||||
"provider": settings.run_sandbox().and_then(|sb| sb.provider.clone()),
|
||||
"preserve": settings.preserve_sandbox_enabled(),
|
||||
},
|
||||
},
|
||||
"labels": labels,
|
||||
|
|
@ -419,14 +423,13 @@ fn create_json_implies_auto_approve() {
|
|||
.expect("create JSON should include run_id");
|
||||
let run = resolve_run(&context, run_id);
|
||||
|
||||
assert_eq!(
|
||||
assert!(
|
||||
run_state(&run.run_dir)
|
||||
.run
|
||||
.as_ref()
|
||||
.expect("run record should exist")
|
||||
.settings
|
||||
.auto_approve,
|
||||
Some(true)
|
||||
.auto_approve_enabled()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ fn exec_uses_user_config_defaults() {
|
|||
let context = test_context!();
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
"[exec]\nprovider = \"openai\"\nmodel = \"gpt-4.1-mini\"\npermissions = \"read-only\"\noutput_format = \"json\"\n",
|
||||
"_version = 1\n\n[cli.exec.model]\nprovider = \"openai\"\nname = \"gpt-4.1-mini\"\n\n[cli.exec.agent]\npermissions = \"read-only\"\n\n[cli.output]\nformat = \"json\"\n",
|
||||
);
|
||||
|
||||
let mut cmd = context.exec_cmd();
|
||||
|
|
@ -166,7 +166,10 @@ fn exec_configured_server_target_alone_does_not_reroute_exec() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = context.exec_cmd();
|
||||
|
|
@ -211,7 +214,7 @@ fn exec_cli_server_target_overrides_configured_server_target() {
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!(
|
||||
"[server]\ntarget = \"{}/api/v1\"\n",
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
config_server.base_url()
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -218,7 +218,10 @@ fn list_uses_configured_server_target_without_server_flag() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = context.model();
|
||||
|
|
@ -277,7 +280,10 @@ fn list_uses_fabro_config_for_machine_settings() {
|
|||
let config_path = config_dir.path().join("custom-settings.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,10 @@ fn ps_uses_configured_server_target_without_server_flag() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let output = context
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ use fabro_test::{fabro_snapshot, test_context};
|
|||
|
||||
fn init_fabro_project(context: &fabro_test::TestContext) {
|
||||
context
|
||||
.write_temp("fabro.toml", "version = 1\n")
|
||||
.write_temp("fabro.toml", "_version = 1\n")
|
||||
.write_temp("fabro/workflows/hello/workflow.fabro", "digraph {}")
|
||||
.write_temp("fabro/workflows/hello/workflow.toml", "version = 1\n");
|
||||
.write_temp("fabro/workflows/hello/workflow.toml", "_version = 1\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -58,16 +58,13 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() {
|
|||
# Fabro project configuration
|
||||
# https://docs.fabro.computer/getting-started/quick-start
|
||||
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[fabro]
|
||||
root = "fabro/"
|
||||
|
||||
# Disable retrospective analysis after workflow runs:
|
||||
# retro = false
|
||||
[project]
|
||||
directory = "fabro/"
|
||||
|
||||
# Auto-create pull requests on successful workflow runs.
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = true
|
||||
# auto_merge = true
|
||||
|
|
@ -94,10 +91,12 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() {
|
|||
std::fs::read_to_string(context.temp_dir.join("fabro/workflows/hello/workflow.toml"))
|
||||
.unwrap(),
|
||||
@r###"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
"###
|
||||
);
|
||||
|
|
@ -107,7 +106,7 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() {
|
|||
fn repo_init_rejects_already_initialized_repo() {
|
||||
let context = test_context!();
|
||||
context.git_init();
|
||||
std::fs::write(context.temp_dir.join("fabro.toml"), "version = 1\n").unwrap();
|
||||
std::fs::write(context.temp_dir.join("fabro.toml"), "_version = 1\n").unwrap();
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["repo", "init"]);
|
||||
|
|
|
|||
|
|
@ -182,7 +182,10 @@ fn rm_force_removes_active_run() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let mut filters = context.filters();
|
||||
|
|
@ -292,7 +295,10 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let output = context
|
||||
|
|
|
|||
|
|
@ -203,7 +203,10 @@ fn detach_uses_configured_server_target_without_server_flag() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let output = context
|
||||
|
|
@ -288,7 +291,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!(
|
||||
"[server]\ntarget = \"{}/api/v1\"\n",
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
config_server.base_url()
|
||||
),
|
||||
);
|
||||
|
|
@ -450,18 +453,22 @@ fn local_foreground_run_prints_artifact_paths_from_server_artifact_list() {
|
|||
);
|
||||
context.write_temp(
|
||||
"artifact-summary/run.toml",
|
||||
r#"version = 1
|
||||
r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[run]
|
||||
goal = "Show stored artifacts"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
preserve = true
|
||||
|
||||
[sandbox.local]
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "never"
|
||||
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["assets/**"]
|
||||
"#,
|
||||
);
|
||||
|
|
@ -531,6 +538,62 @@ fn dry_run_simple() {
|
|||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_with_goal_file_reads_contents_into_goal() {
|
||||
// Regression test for the `--goal-file` flag that was previously
|
||||
// being silently ignored in the v2 path. The file content must end
|
||||
// up in the effective goal displayed in the preflight summary.
|
||||
let context = test_context!();
|
||||
|
||||
let goal_dir = tempfile::tempdir().unwrap();
|
||||
let goal_path = goal_dir.path().join("goal.md");
|
||||
std::fs::write(&goal_path, "Ship the rate-limiting feature end to end.\n").unwrap();
|
||||
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.args(["--dry-run", "--auto-approve", "--goal-file"]);
|
||||
cmd.arg(&goal_path);
|
||||
cmd.arg(example_fixture("simple.fabro"));
|
||||
|
||||
let output = cmd.output().expect("run command should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"run should succeed:\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("Ship the rate-limiting feature end to end."),
|
||||
"goal file content should appear in preflight summary, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_rejects_goal_and_goal_file_together() {
|
||||
// clap `conflicts_with` must fire when both flags are supplied.
|
||||
let context = test_context!();
|
||||
|
||||
let goal_dir = tempfile::tempdir().unwrap();
|
||||
let goal_path = goal_dir.path().join("goal.md");
|
||||
std::fs::write(&goal_path, "never read").unwrap();
|
||||
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.args(["--dry-run", "--goal", "inline override", "--goal-file"]);
|
||||
cmd.arg(&goal_path);
|
||||
cmd.arg(example_fixture("simple.fabro"));
|
||||
let output = cmd.output().expect("run command should execute");
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"run should fail when --goal and --goal-file are both set"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("cannot be used with")
|
||||
|| stderr.contains("conflict")
|
||||
|| stderr.to_lowercase().contains("mutually exclusive"),
|
||||
"expected conflicts_with error, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_persists_event_history_in_store() {
|
||||
let context = test_context!();
|
||||
|
|
@ -581,9 +644,9 @@ fn dry_run_persists_event_history_in_store() {
|
|||
assert_eq!(
|
||||
progress
|
||||
.first()
|
||||
.and_then(|event| event.pointer("/properties/settings/auto_approve"))
|
||||
.and_then(Value::as_bool),
|
||||
Some(true)
|
||||
.and_then(|event| event.pointer("/properties/settings/run/execution/approval"))
|
||||
.and_then(Value::as_str),
|
||||
Some("auto")
|
||||
);
|
||||
assert!(
|
||||
progress
|
||||
|
|
@ -713,17 +776,35 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
);
|
||||
}
|
||||
}
|
||||
let Some(llm) = event.pointer_mut("/properties/settings/llm") else {
|
||||
// Strip fields that vary between runs (version, server stanzas that
|
||||
// carry machine-specific values, cli.target sockets).
|
||||
if let Some(settings) = event
|
||||
.pointer_mut("/properties/settings")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
settings.remove("_version");
|
||||
settings.remove("server");
|
||||
settings.remove("version");
|
||||
}
|
||||
if let Some(target) = event
|
||||
.pointer_mut("/properties/settings/cli/target")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
if target.contains_key("path") {
|
||||
target.insert(
|
||||
"path".to_string(),
|
||||
Value::String("[CLI_SOCKET]".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(model) = event.pointer_mut("/properties/settings/run/model") else {
|
||||
continue;
|
||||
};
|
||||
let Some(llm) = llm.as_object_mut() else {
|
||||
let Some(model) = model.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
llm.insert(
|
||||
"model".to_string(),
|
||||
Value::String("[LLM_MODEL]".to_string()),
|
||||
);
|
||||
llm.insert(
|
||||
model.insert("name".to_string(), Value::String("[LLM_MODEL]".to_string()));
|
||||
model.insert(
|
||||
"provider".to_string(),
|
||||
Value::String("[LLM_PROVIDER]".to_string()),
|
||||
);
|
||||
|
|
@ -851,23 +932,26 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
},
|
||||
"run_dir": "[RUN_DIR]",
|
||||
"settings": {
|
||||
"auto_approve": true,
|
||||
"goal": "Route through the default approval path",
|
||||
"llm": {
|
||||
"fallbacks": null,
|
||||
"model": "[LLM_MODEL]",
|
||||
"provider": "[LLM_PROVIDER]"
|
||||
"run": {
|
||||
"execution": {
|
||||
"approval": "auto",
|
||||
"retros": false
|
||||
},
|
||||
"goal": "Route through the default approval path",
|
||||
"model": {
|
||||
"name": "[LLM_MODEL]",
|
||||
"provider": "[LLM_PROVIDER]"
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": "local"
|
||||
}
|
||||
},
|
||||
"no_retro": true,
|
||||
"sandbox": {
|
||||
"daytona": null,
|
||||
"devcontainer": null,
|
||||
"env": null,
|
||||
"local": null,
|
||||
"preserve": null,
|
||||
"provider": "local"
|
||||
},
|
||||
"storage_dir": "[STORAGE_DIR]"
|
||||
"cli": {
|
||||
"target": {
|
||||
"path": "[CLI_SOCKET]",
|
||||
"type": "unix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workflow_slug": "human-gate",
|
||||
"workflow_source": "digraph HumanGate {/n graph [goal=\"Route through the default approval path\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n",
|
||||
|
|
@ -1429,8 +1513,8 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"#);
|
||||
|
||||
assert_eq!(
|
||||
progress[0].pointer("/properties/settings/auto_approve"),
|
||||
Some(&serde_json::json!(true))
|
||||
progress[0].pointer("/properties/settings/run/execution/approval"),
|
||||
Some(&serde_json::json!("auto"))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -177,9 +177,9 @@ fn runner_uses_snapshotted_app_id_for_github_credentials() {
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
"\
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[git]
|
||||
[server.integrations.github]
|
||||
app_id = \"snapshotted-app-id\"
|
||||
",
|
||||
);
|
||||
|
|
@ -213,7 +213,7 @@ digraph GitHubApp {
|
|||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
"app_id": run.settings.git.clone().and_then(|git| git.app_id),
|
||||
"app_id": run.settings.github_app_id_str(),
|
||||
}),
|
||||
@r#"
|
||||
{
|
||||
|
|
@ -222,7 +222,7 @@ digraph GitHubApp {
|
|||
"#
|
||||
);
|
||||
|
||||
context.write_home(".fabro/settings.toml", "version = 1\n");
|
||||
context.write_home(".fabro/settings.toml", "_version = 1\n");
|
||||
|
||||
let server = server_target(&context.storage_dir);
|
||||
let mut cmd = context.command();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV;
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
|
@ -143,8 +144,12 @@ fn start_with_tcp_host_only_bind_resolves_to_host_and_port() {
|
|||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
|
||||
// TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is
|
||||
// exercising bind resolution, not auth, so opt into insecure
|
||||
// startup explicitly.
|
||||
let mut cmd = context.command();
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
|
||||
cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1");
|
||||
cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]);
|
||||
let output = cmd.output().expect("server start command should run");
|
||||
assert!(
|
||||
|
|
@ -202,8 +207,12 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava
|
|||
filters.push((r"pid \d+".to_string(), "pid [PID]".to_string()));
|
||||
filters.push((r"127\.0\.0\.1:\d+".to_string(), "[TCP_BIND]".to_string()));
|
||||
|
||||
// TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is
|
||||
// exercising bind resolution, not auth, so opt into insecure
|
||||
// startup explicitly.
|
||||
let mut cmd = context.command();
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
|
||||
cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1");
|
||||
cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
|
|
@ -368,7 +377,7 @@ fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() {
|
|||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"storage_dir = \"{}\"\n[server]\ntarget = \"{}\"\n",
|
||||
"_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n",
|
||||
storage_dir.display(),
|
||||
socket_path.display()
|
||||
),
|
||||
|
|
|
|||
|
|
@ -137,18 +137,22 @@ fn store_dump_exports_blob_refs_and_artifacts_together() {
|
|||
.unwrap();
|
||||
fs::write(
|
||||
workspace_dir.join("run.toml"),
|
||||
r#"version = 1
|
||||
r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "mixed-export.fabro"
|
||||
|
||||
[run]
|
||||
goal = "Generate oversized command output and artifacts"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
preserve = true
|
||||
|
||||
[sandbox.local]
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "never"
|
||||
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["assets/**"]
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ pub(crate) fn setup_project_fixture(context: &TestContext) -> ProjectFixture {
|
|||
let fabro_root = project_dir.join("fabro");
|
||||
write_text_file(
|
||||
&project_dir.join("fabro.toml"),
|
||||
"version = 1\n[fabro]\nroot = \"fabro/\"\n",
|
||||
"_version = 1\n\n[project]\ndirectory = \"fabro/\"\n",
|
||||
);
|
||||
std::fs::create_dir_all(fabro_root.join("workflows"))
|
||||
.unwrap_or_else(|err| panic!("failed to create {}: {err}", fabro_root.display()));
|
||||
|
|
@ -306,18 +306,22 @@ pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup {
|
|||
);
|
||||
write_text_file(
|
||||
&workspace_dir.join("run.toml"),
|
||||
r#"version = 1
|
||||
r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "artifact_run.fabro"
|
||||
|
||||
[run]
|
||||
goal = "Exercise artifact commands"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
preserve = true
|
||||
|
||||
[sandbox.local]
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "never"
|
||||
|
||||
[artifacts]
|
||||
[run.artifacts]
|
||||
include = ["assets/**"]
|
||||
"#,
|
||||
);
|
||||
|
|
@ -345,15 +349,19 @@ pub(crate) fn setup_local_sandbox_run(context: &TestContext) -> WorkspaceRunSetu
|
|||
);
|
||||
write_text_file(
|
||||
&workspace_dir.join("run.toml"),
|
||||
r#"version = 1
|
||||
r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "sandbox_run.fabro"
|
||||
|
||||
[run]
|
||||
goal = "Exercise sandbox commands"
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
preserve = true
|
||||
|
||||
[sandbox.local]
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "never"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -408,7 +416,9 @@ pub(crate) fn add_project_workflow(
|
|||
write_text_file(&workflow_dir.join("workflow.fabro"), dot_source);
|
||||
write_text_file(
|
||||
&workflow_dir.join("workflow.toml"),
|
||||
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
|
||||
&format!(
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n"
|
||||
),
|
||||
);
|
||||
workflow_dir
|
||||
}
|
||||
|
|
@ -419,7 +429,9 @@ pub(crate) fn add_user_workflow(context: &TestContext, name: &str, goal: &str) -
|
|||
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display()));
|
||||
write_text_file(
|
||||
&workflow_dir.join("workflow.toml"),
|
||||
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
|
||||
&format!(
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n"
|
||||
),
|
||||
);
|
||||
write_text_file(
|
||||
&workflow_dir.join("workflow.fabro"),
|
||||
|
|
@ -791,15 +803,19 @@ pub(crate) fn compact_inspect(output: &Output) -> Value {
|
|||
let checkpoint = item["checkpoint"].clone();
|
||||
let conclusion = item["conclusion"].clone();
|
||||
let sandbox = item["sandbox"].clone();
|
||||
let dry_run = run_record
|
||||
.pointer("/settings/run/execution/mode")
|
||||
.and_then(Value::as_str)
|
||||
.map(|mode| Value::Bool(mode == "dry_run"));
|
||||
serde_json::json!({
|
||||
"run_id": "[ULID]",
|
||||
"status": item["status"],
|
||||
"run_record": {
|
||||
"goal": run_record.pointer("/settings/goal"),
|
||||
"goal": run_record.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_record.pointer("/graph/name"),
|
||||
"workflow_slug": run_record.pointer("/workflow_slug"),
|
||||
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
|
||||
"dry_run": run_record.pointer("/settings/dry_run"),
|
||||
"sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"),
|
||||
"dry_run": dry_run,
|
||||
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
|
||||
serde_json::json!({
|
||||
"server_version": "[VERSION]",
|
||||
|
|
@ -854,11 +870,11 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
|
|||
"run_id": "[ULID]",
|
||||
"status": item["status"],
|
||||
"run_record": {
|
||||
"goal": run_record.pointer("/settings/goal"),
|
||||
"goal": run_record.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_record.pointer("/graph/name"),
|
||||
"workflow_slug": run_record.pointer("/workflow_slug"),
|
||||
"llm_provider": run_record.pointer("/settings/llm/provider"),
|
||||
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
|
||||
"llm_provider": run_record.pointer("/settings/run/model/provider"),
|
||||
"sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"),
|
||||
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
|
||||
serde_json::json!({
|
||||
"server_version": "[VERSION]",
|
||||
|
|
|
|||
|
|
@ -34,10 +34,13 @@ fn list() {
|
|||
let context = test_context!();
|
||||
|
||||
context
|
||||
.write_temp("fabro.toml", "version = 1\n")
|
||||
.write_temp(
|
||||
"fabro.toml",
|
||||
"_version = 1\n\n[project]\ndirectory = \".\"\n",
|
||||
)
|
||||
.write_temp(
|
||||
"workflows/my_test_wf/workflow.toml",
|
||||
"version = 1\ngoal = \"A test workflow\"\n",
|
||||
"_version = 1\n\n[run]\ngoal = \"A test workflow\"\n",
|
||||
);
|
||||
|
||||
let mut cmd = context.command();
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ fn workflow_create_writes_scaffold_files() {
|
|||
std::fs::read_to_string(project.fabro_root.join("workflows/hello-world/workflow.toml"))
|
||||
.unwrap(),
|
||||
@r###"
|
||||
version = 1
|
||||
_version = 1
|
||||
"###
|
||||
);
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ fn workflow_create_rejects_existing_workflow() {
|
|||
std::fs::create_dir_all(project.fabro_root.join("workflows/existing")).unwrap();
|
||||
std::fs::write(
|
||||
project.fabro_root.join("workflows/existing/workflow.toml"),
|
||||
"version = 1\n",
|
||||
"_version = 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -163,7 +163,7 @@ fn workflow_create_json_uses_resolved_custom_root_paths() {
|
|||
let project_dir = context.temp_dir.join("project");
|
||||
context.write_temp(
|
||||
"project/fabro.toml",
|
||||
"version = 1\n[fabro]\nroot = \"custom/fabro-data\"\n",
|
||||
"_version = 1\n\n[project]\ndirectory = \"custom/fabro-data\"\n",
|
||||
);
|
||||
|
||||
let output = context
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
|
|||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!(
|
||||
"[server]\ntarget = \"{}/api/v1\"\n",
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
success_server.base_url()
|
||||
),
|
||||
);
|
||||
|
|
@ -400,7 +400,10 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
|
|||
});
|
||||
context.write_home(
|
||||
".fabro/settings.toml",
|
||||
format!("[server]\ntarget = \"{}/api/v1\"\n", eof_server.base_url()),
|
||||
format!(
|
||||
"_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n",
|
||||
eof_server.base_url()
|
||||
),
|
||||
);
|
||||
|
||||
let eof_output = context
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
pub use fabro_types::combine::*;
|
||||
|
|
@ -1,202 +1,118 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
//! v2-backed configuration layer.
|
||||
//!
|
||||
//! `ConfigLayer` is a newtype over [`SettingsFile`] — the v2 namespaced
|
||||
//! parse tree in `fabro_types::settings::v2`. Loading functions (`parse`,
|
||||
//! `load`, `for_workflow`, `project`, `settings`) all hard-fail on legacy
|
||||
//! top-level keys with targeted rename hints. `ConfigLayer::combine` walks
|
||||
//! the v2 merge matrix from [`crate::merge`].
|
||||
//!
|
||||
//! Consumers that need the inner tree call [`ConfigLayer::as_v2`] (borrow)
|
||||
//! or `.into()` to move out an owned `SettingsFile`. The legacy flat
|
||||
//! `Settings` shape is no longer reachable from this layer.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use fabro_types::settings::accessors::resolve_goal_file_path;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::RunGoalLayer;
|
||||
use fabro_types::settings::{SettingsFile, parse_settings_file as parse_v2_settings_file};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::combine::Combine;
|
||||
use crate::hook::{HookDefinition, HookSettings};
|
||||
use crate::mcp::McpServerEntry;
|
||||
use crate::project::{self, ProjectConfig};
|
||||
use crate::run::{
|
||||
ArtifactsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
|
||||
};
|
||||
use crate::sandbox::SandboxConfig;
|
||||
use crate::server::{ApiConfig, FeaturesConfig, GitConfig, LogConfig, SlackConfig, WebConfig};
|
||||
use crate::user::{self, ExecConfig, ServerConfig};
|
||||
use fabro_types::Settings;
|
||||
use crate::merge::combine_files;
|
||||
use crate::project::{self};
|
||||
use crate::user;
|
||||
|
||||
fn is_default_checkpoint(c: &CheckpointConfig) -> bool {
|
||||
c.exclude_globs.is_empty()
|
||||
}
|
||||
|
||||
/// Unified sparse configuration type for all Fabro config sources.
|
||||
/// Rewrite any relative `run.goal = { file = "..." }` path in `file` to an
|
||||
/// absolute path anchored at `base_dir`.
|
||||
///
|
||||
/// Loading functions (`load_settings_config`, `load_run_config`,
|
||||
/// `parse_project_config`) all return this type. Fields irrelevant to a
|
||||
/// particular source are left unset (`None` / empty).
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct ConfigLayer {
|
||||
// --- Workflow run config fields ---
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<u32>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub goal: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub goal_file: Option<PathBuf>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub labels: HashMap<String, String>,
|
||||
|
||||
// --- Run defaults fields (inlined) ---
|
||||
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
|
||||
pub work_dir: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub llm: Option<LlmConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub setup: Option<SetupConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<SandboxConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vars: Option<HashMap<String, String>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
|
||||
pub checkpoint: CheckpointConfig,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pull_request: Option<PullRequestConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artifacts: Option<ArtifactsConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub github: Option<GitHubConfig>,
|
||||
|
||||
// --- User config fields ---
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server: Option<ServerConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exec: Option<ExecConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prevent_idle_sleep: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verbose: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upgrade_check: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dry_run: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_approve: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub no_retro: Option<bool>,
|
||||
|
||||
// --- Server config fields ---
|
||||
#[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")]
|
||||
pub storage_dir: Option<PathBuf>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrent_runs: Option<usize>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artifact_storage: Option<fabro_types::ArtifactStorageSettings>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub web: Option<WebConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slack: Option<SlackConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<ApiConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub features: Option<FeaturesConfig>,
|
||||
|
||||
// --- Shared fields ---
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log: Option<LogConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitConfig>,
|
||||
|
||||
// --- Project config fields ---
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fabro: Option<ProjectConfig>,
|
||||
/// Called from `ConfigLayer::load` so that layers coming from different
|
||||
/// config files can be merged without losing the "relative to my source
|
||||
/// file" context. Paths that contain `${env.NAME}` interpolation are left
|
||||
/// alone (they get resolved against the run's working directory at consume
|
||||
/// time via [`SettingsFile::resolve_run_goal`]).
|
||||
fn resolve_goal_file_paths(file: &mut SettingsFile, base_dir: &Path) {
|
||||
let Some(run) = file.run.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(RunGoalLayer::File { file: goal_file }) = run.goal.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if !goal_file.is_literal() {
|
||||
// Env-tokenized paths stay unresolved until consume time.
|
||||
return;
|
||||
}
|
||||
let literal = goal_file.as_source();
|
||||
if Path::new(&literal).is_absolute() {
|
||||
return;
|
||||
}
|
||||
let absolute = resolve_goal_file_path(&literal, base_dir);
|
||||
*goal_file = InterpString::parse(&absolute.to_string_lossy());
|
||||
}
|
||||
|
||||
impl Combine for ConfigLayer {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
let hooks = if self.hooks.is_empty() {
|
||||
other.hooks
|
||||
} else if other.hooks.is_empty() {
|
||||
self.hooks
|
||||
} else {
|
||||
HookSettings { hooks: other.hooks }
|
||||
.merge(HookSettings { hooks: self.hooks })
|
||||
.hooks
|
||||
};
|
||||
/// A parsed settings file layer.
|
||||
///
|
||||
/// Thin newtype around the v2 [`SettingsFile`] parse tree. The newtype
|
||||
/// exists so fabro-config can attach helper methods and evolve the
|
||||
/// internal representation without forcing every caller to import v2
|
||||
/// types.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ConfigLayer {
|
||||
pub file: SettingsFile,
|
||||
}
|
||||
|
||||
Self {
|
||||
version: self.version.combine(other.version),
|
||||
goal: self.goal.combine(other.goal),
|
||||
goal_file: self.goal_file.combine(other.goal_file),
|
||||
graph: self.graph.combine(other.graph),
|
||||
labels: self.labels.combine(other.labels),
|
||||
work_dir: self.work_dir.combine(other.work_dir),
|
||||
llm: self.llm.combine(other.llm),
|
||||
setup: self.setup.combine(other.setup),
|
||||
sandbox: self.sandbox.combine(other.sandbox),
|
||||
vars: self.vars.combine(other.vars),
|
||||
checkpoint: self.checkpoint.combine(other.checkpoint),
|
||||
pull_request: self.pull_request.combine(other.pull_request),
|
||||
artifacts: self.artifacts.combine(other.artifacts),
|
||||
hooks,
|
||||
mcp_servers: self.mcp_servers.combine(other.mcp_servers),
|
||||
github: self.github.combine(other.github),
|
||||
server: self.server.combine(other.server),
|
||||
exec: self.exec.combine(other.exec),
|
||||
prevent_idle_sleep: self.prevent_idle_sleep.combine(other.prevent_idle_sleep),
|
||||
verbose: self.verbose.combine(other.verbose),
|
||||
upgrade_check: self.upgrade_check.combine(other.upgrade_check),
|
||||
dry_run: self.dry_run.combine(other.dry_run),
|
||||
auto_approve: self.auto_approve.combine(other.auto_approve),
|
||||
no_retro: self.no_retro.combine(other.no_retro),
|
||||
storage_dir: self.storage_dir.combine(other.storage_dir),
|
||||
max_concurrent_runs: self.max_concurrent_runs.combine(other.max_concurrent_runs),
|
||||
artifact_storage: self.artifact_storage.combine(other.artifact_storage),
|
||||
web: self.web.combine(other.web),
|
||||
slack: self.slack.combine(other.slack),
|
||||
api: self.api.combine(other.api),
|
||||
features: self.features.combine(other.features),
|
||||
log: self.log.combine(other.log),
|
||||
git: self.git.combine(other.git),
|
||||
fabro: self.fabro.combine(other.fabro),
|
||||
}
|
||||
impl From<SettingsFile> for ConfigLayer {
|
||||
fn from(file: SettingsFile) -> Self {
|
||||
Self { file }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigLayer> for SettingsFile {
|
||||
fn from(layer: ConfigLayer) -> Self {
|
||||
layer.file
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigLayer {
|
||||
/// Combine two layers using the v2 merge matrix.
|
||||
#[must_use]
|
||||
pub fn combine(self, other: Self) -> Self {
|
||||
Combine::combine(self, other)
|
||||
// In the legacy contract `self.combine(other)` means `self` is the
|
||||
// higher-precedence layer and `other` is the lower-precedence one.
|
||||
// The merge matrix walker takes (lower, higher).
|
||||
Self {
|
||||
file: combine_files(other.file, self.file),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a v2 TOML settings file into a layer.
|
||||
pub fn parse(content: &str) -> anyhow::Result<Self> {
|
||||
let file = parse_v2_settings_file(content)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("Failed to parse settings file")?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
/// Load a v2 TOML settings file from disk.
|
||||
///
|
||||
/// Relative `run.goal = { file = "..." }` paths are resolved against
|
||||
/// the directory of `path` at load time. Subsequent merging with other
|
||||
/// layers can then safely treat the path as self-contained.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let mut layer = Self::parse(&content)?;
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
resolve_goal_file_paths(&mut layer.file, base_dir);
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
/// Load workflow config + project config for a workflow path.
|
||||
///
|
||||
/// Resolves the workflow path, loads its config, discovers project config
|
||||
/// (`fabro.toml`) from the resolved workflow's parent directory, and combines
|
||||
/// them (workflow takes precedence over project).
|
||||
/// (`fabro.toml`) from the resolved workflow's parent directory, and
|
||||
/// combines them (workflow takes precedence over project).
|
||||
pub fn for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<Self> {
|
||||
let resolution = project::resolve_workflow_path(path, cwd)?;
|
||||
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
|
||||
|
|
@ -231,8 +147,230 @@ impl ConfigLayer {
|
|||
user::load_settings_config(None)
|
||||
}
|
||||
|
||||
/// Convert this combined config layer into final resolved settings.
|
||||
pub fn resolve(self) -> anyhow::Result<Settings> {
|
||||
self.try_into()
|
||||
/// Borrow the inner v2 settings file for direct access.
|
||||
#[must_use]
|
||||
pub fn as_v2(&self) -> &SettingsFile {
|
||||
&self.file
|
||||
}
|
||||
|
||||
/// Mutably borrow the inner v2 settings file.
|
||||
pub fn as_v2_mut(&mut self) -> &mut SettingsFile {
|
||||
&mut self.file
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::run::RunGoalLayer;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_legacy_flat_keys() {
|
||||
let err = ConfigLayer::parse("[llm]\nprovider = \"openai\"").unwrap_err();
|
||||
let text = format!("{err:#}");
|
||||
assert!(
|
||||
text.contains("run.model") || text.contains("llm"),
|
||||
"expected rename hint in error: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_inline_goal() {
|
||||
let layer = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run]
|
||||
goal = "Do things"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layer.file.run_goal_inline_str().as_deref(),
|
||||
Some("Do things")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_file_variant() {
|
||||
let layer = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
|
||||
panic!("expected run.goal.file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), "prompts/goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_goal_with_unknown_sibling_fields() {
|
||||
// The untagged enum should reject any `{ file = ..., extra = ... }`
|
||||
// shape because neither the inline nor the file variant matches.
|
||||
let err = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
extra = "boom"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
let text = format!("{err:#}");
|
||||
assert!(text.to_lowercase().contains("run.goal") || text.contains("extra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_prefers_higher_precedence_self() {
|
||||
let higher = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run]
|
||||
goal = "higher goal"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lower = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run]
|
||||
goal = "lower goal"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let merged = higher.combine(lower);
|
||||
assert_eq!(
|
||||
merged.file.run_goal_inline_str().as_deref(),
|
||||
Some("higher goal")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_replaces_file_goal_with_inline_from_higher_layer() {
|
||||
// A higher-precedence `run.goal = "inline"` must fully override a
|
||||
// lower layer's `run.goal = { file = "..." }` — the scalar merge
|
||||
// treats `goal` as one field regardless of which variant each
|
||||
// layer picked.
|
||||
let higher = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run]
|
||||
goal = "inline override"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lower = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "/tmp/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let merged = higher.combine(lower);
|
||||
assert_eq!(
|
||||
merged.file.run_goal_inline_str().as_deref(),
|
||||
Some("inline override")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_replaces_inline_goal_with_file_from_higher_layer() {
|
||||
let higher = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "/tmp/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lower = ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
[run]
|
||||
goal = "inline loser"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let merged = higher.combine(lower);
|
||||
assert!(matches!(
|
||||
merged.file.run_goal_layer(),
|
||||
Some(RunGoalLayer::File { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rewrites_relative_goal_file_to_absolute() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("fabro.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let layer = ConfigLayer::load(&config_path).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
let resolved = file.as_source();
|
||||
let expected = tmp.path().join("prompts").join("goal.md");
|
||||
assert_eq!(resolved, expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_leaves_absolute_goal_file_untouched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("fabro.toml");
|
||||
let abs_goal = "/etc/fabro/goal.md";
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "{abs_goal}"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let layer = ConfigLayer::load(&config_path).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), abs_goal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_leaves_env_interpolated_goal_file_untouched() {
|
||||
// InterpString paths aren't resolved at load time because env
|
||||
// lookups happen at consume time. The loader should leave them
|
||||
// alone.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("fabro.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
_version = 1
|
||||
[run.goal]
|
||||
file = "${env.GOALS_DIR}/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let layer = ConfigLayer::load(&config_path).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), "${env.GOALS_DIR}/goal.md");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
//! Effective settings resolution: combine layers into one resolved [`SettingsFile`].
|
||||
//!
|
||||
//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge
|
||||
//! across all three config files (settings.toml, fabro.toml, workflow.toml).
|
||||
//! Owner-specific domains (`cli`, `server`) are consumed only from the local
|
||||
//! `~/.fabro/settings.toml` plus explicit process-local overrides — their
|
||||
//! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::settings::run::{RunExecutionLayer, RunLayer};
|
||||
use fabro_types::settings::server::ServerLayer;
|
||||
|
||||
use crate::ConfigLayer;
|
||||
use crate::merge::combine_files;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EffectiveSettingsMode {
|
||||
|
|
@ -35,97 +46,145 @@ impl EffectiveSettingsLayers {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve layered configuration down to a single effective [`SettingsFile`].
|
||||
pub fn resolve_settings(
|
||||
layers: EffectiveSettingsLayers,
|
||||
server_settings: Option<&Settings>,
|
||||
server_settings: Option<&SettingsFile>,
|
||||
mode: EffectiveSettingsMode,
|
||||
) -> Result<Settings> {
|
||||
) -> Result<SettingsFile> {
|
||||
let EffectiveSettingsLayers {
|
||||
args,
|
||||
mut workflow,
|
||||
mut project,
|
||||
mut user,
|
||||
user,
|
||||
} = layers;
|
||||
|
||||
match mode {
|
||||
EffectiveSettingsMode::LocalOnly => args
|
||||
.combine(workflow)
|
||||
.combine(project)
|
||||
.combine(user)
|
||||
.resolve(),
|
||||
EffectiveSettingsMode::LocalOnly => {
|
||||
Ok(args.combine(workflow).combine(project).combine(user).into())
|
||||
}
|
||||
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
|
||||
let server_settings = server_settings.ok_or_else(|| {
|
||||
anyhow!("server settings are required for server-targeted settings resolution")
|
||||
})?;
|
||||
strip_server_owned_fields(&mut workflow);
|
||||
strip_server_owned_fields(&mut project);
|
||||
strip_server_owned_fields(&mut user);
|
||||
// Owner-specific domains (cli, server) may only come from the
|
||||
// local ~/.fabro/settings.toml, never from fabro.toml or
|
||||
// workflow.toml. The user layer keeps its cli/server fields.
|
||||
strip_owner_domains(workflow.as_v2_mut());
|
||||
strip_owner_domains(project.as_v2_mut());
|
||||
|
||||
let server_defaults = match mode {
|
||||
EffectiveSettingsMode::RemoteServer => server_defaults_layer(server_settings)?,
|
||||
let server_defaults = server_defaults_file(server_settings);
|
||||
|
||||
let combined: SettingsFile =
|
||||
args.combine(workflow).combine(project).combine(user).into();
|
||||
|
||||
let mut settings = match mode {
|
||||
EffectiveSettingsMode::RemoteServer => {
|
||||
apply_server_defaults(combined, &server_defaults)
|
||||
}
|
||||
EffectiveSettingsMode::LocalDaemon => {
|
||||
local_daemon_server_overrides_layer(server_settings)?
|
||||
apply_local_daemon_overrides(combined, &server_defaults)
|
||||
}
|
||||
EffectiveSettingsMode::LocalOnly => unreachable!(),
|
||||
};
|
||||
|
||||
let mut settings = args
|
||||
.combine(workflow)
|
||||
.combine(project)
|
||||
.combine(user)
|
||||
.combine(server_defaults)
|
||||
.resolve()?;
|
||||
settings
|
||||
.storage_dir
|
||||
.clone_from(&server_settings.storage_dir);
|
||||
// Storage root always comes from the server's local
|
||||
// ~/.fabro/settings.toml, never from the client.
|
||||
if let Some(server_root) = server_settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.storage.as_ref())
|
||||
.cloned()
|
||||
{
|
||||
let server = settings.server.get_or_insert_with(ServerLayer::default);
|
||||
server.storage = Some(server_root);
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_defaults_layer(settings: &Settings) -> Result<ConfigLayer> {
|
||||
let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?;
|
||||
// Run manifests carry their own dry-run intent. Do not let a daemon's
|
||||
// startup-time fallback mode silently force every submitted run/preflight
|
||||
// into simulation.
|
||||
layer.dry_run = None;
|
||||
Ok(layer)
|
||||
fn strip_owner_domains(file: &mut SettingsFile) {
|
||||
file.cli = None;
|
||||
file.server = None;
|
||||
}
|
||||
|
||||
fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLayer> {
|
||||
let layer = server_defaults_layer(settings)?;
|
||||
Ok(ConfigLayer {
|
||||
storage_dir: layer.storage_dir,
|
||||
max_concurrent_runs: layer.max_concurrent_runs,
|
||||
artifact_storage: layer.artifact_storage,
|
||||
web: layer.web,
|
||||
api: layer.api,
|
||||
features: layer.features,
|
||||
..Default::default()
|
||||
})
|
||||
/// Copy of the server settings with startup-time dry-run fallback cleared.
|
||||
/// Run manifests carry their own dry-run intent; a daemon's startup-time
|
||||
/// fallback mode must not silently force every submitted run into simulation.
|
||||
fn server_defaults_file(settings: &SettingsFile) -> SettingsFile {
|
||||
let mut out = settings.clone();
|
||||
if let Some(run) = out.run.as_mut() {
|
||||
if let Some(execution) = run.execution.as_mut() {
|
||||
execution.mode = None;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_server_owned_fields(layer: &mut ConfigLayer) {
|
||||
layer.server = None;
|
||||
layer.exec = None;
|
||||
layer.storage_dir = None;
|
||||
layer.max_concurrent_runs = None;
|
||||
layer.artifact_storage = None;
|
||||
layer.web = None;
|
||||
layer.api = None;
|
||||
layer.features = None;
|
||||
layer.log = None;
|
||||
/// Apply server-side defaults to a client-layered [`SettingsFile`].
|
||||
///
|
||||
/// Server-owned domains (`server`, `features`, and parts of `run`) flow from
|
||||
/// the server's local `~/.fabro/settings.toml` when the corresponding client
|
||||
/// value is absent. Run-shaped defaults (model, prepare, sandbox, checkpoint,
|
||||
/// hooks, agent mcps, etc.) also flow from server to client so the persisted
|
||||
/// run record matches the server's local configuration.
|
||||
fn apply_server_defaults(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile {
|
||||
// Server-owned domains: server-side always wins when client left blank.
|
||||
// Use the v2 merge matrix with the server layer in lower precedence so
|
||||
// that client-supplied values still dominate when present.
|
||||
settings = combine_files(server.clone(), settings);
|
||||
settings
|
||||
}
|
||||
|
||||
/// Apply server-side overrides in LocalDaemon mode.
|
||||
///
|
||||
/// In LocalDaemon mode, a subset of server-owned fields unconditionally
|
||||
/// override any client-side values. Client-controlled run-level fields are
|
||||
/// left alone.
|
||||
fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile {
|
||||
if let Some(server_layer) = server.server.clone() {
|
||||
let client = settings.server.get_or_insert_with(ServerLayer::default);
|
||||
if let Some(storage) = server_layer.storage {
|
||||
client.storage = Some(storage);
|
||||
}
|
||||
if let Some(scheduler) = server_layer.scheduler {
|
||||
client.scheduler = Some(scheduler);
|
||||
}
|
||||
if let Some(artifacts) = server_layer.artifacts {
|
||||
client.artifacts = Some(artifacts);
|
||||
}
|
||||
if let Some(web) = server_layer.web {
|
||||
client.web = Some(web);
|
||||
}
|
||||
if let Some(api) = server_layer.api {
|
||||
client.api = Some(api);
|
||||
}
|
||||
}
|
||||
if let Some(features) = server.features.clone() {
|
||||
settings.features = Some(features);
|
||||
}
|
||||
// Ensure a run.execution table exists so downstream consumers that check
|
||||
// for explicit dry-run defaults see a well-formed layer.
|
||||
settings.run.get_or_insert_with(RunLayer::default);
|
||||
settings
|
||||
.run
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.execution
|
||||
.get_or_insert_with(RunExecutionLayer::default);
|
||||
settings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer};
|
||||
|
||||
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings};
|
||||
use crate::ConfigLayer;
|
||||
|
||||
fn layer(source: &str) -> ConfigLayer {
|
||||
toml::from_str(source).expect("config layer fixture should parse")
|
||||
ConfigLayer::parse(source).expect("v2 fixture should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -136,22 +195,27 @@ mod tests {
|
|||
ConfigLayer::default(),
|
||||
layer(
|
||||
r#"
|
||||
[llm]
|
||||
model = "project-model"
|
||||
_version = 1
|
||||
|
||||
[vars]
|
||||
[run.model]
|
||||
name = "project-model"
|
||||
|
||||
[run.inputs]
|
||||
project_only = "1"
|
||||
shared = "project"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
r#"
|
||||
storage_dir = "/tmp/local-storage"
|
||||
_version = 1
|
||||
|
||||
[llm]
|
||||
[server.storage]
|
||||
root = "/tmp/local-storage"
|
||||
|
||||
[run.model]
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
[run.inputs]
|
||||
user_only = "1"
|
||||
shared = "user"
|
||||
"#,
|
||||
|
|
@ -162,68 +226,55 @@ shared = "user"
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = settings.llm.expect("llm config");
|
||||
assert_eq!(llm.model.as_deref(), Some("project-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(
|
||||
settings.storage_dir,
|
||||
Some(PathBuf::from("/tmp/local-storage"))
|
||||
settings.run_model_name_str().as_deref(),
|
||||
Some("project-model")
|
||||
);
|
||||
// Per R22, run.inputs replaces wholesale — the winning layer is the
|
||||
// highest-precedence layer that sets `inputs` (project here, since it
|
||||
// wins over user).
|
||||
let inputs = settings.run_inputs().unwrap();
|
||||
assert!(inputs.contains_key("project_only"));
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("project_only")),
|
||||
Some(&"1".to_string())
|
||||
inputs.get("shared").and_then(|v| v.as_str()),
|
||||
Some("project")
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("user_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
|
||||
Some(&"project".to_string())
|
||||
assert!(
|
||||
!inputs.contains_key("user_only"),
|
||||
"project.inputs should replace user.inputs wholesale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_merges_workflow_project_and_user_layers() {
|
||||
fn local_only_merges_workflow_project_user() {
|
||||
let settings = resolve_settings(
|
||||
EffectiveSettingsLayers::new(
|
||||
ConfigLayer::default(),
|
||||
layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run]
|
||||
goal = "workflow goal"
|
||||
|
||||
[llm]
|
||||
model = "workflow-model"
|
||||
|
||||
[vars]
|
||||
workflow_only = "1"
|
||||
shared = "workflow"
|
||||
[run.model]
|
||||
name = "workflow-model"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
r#"
|
||||
[llm]
|
||||
model = "project-model"
|
||||
_version = 1
|
||||
|
||||
[vars]
|
||||
project_only = "1"
|
||||
shared = "project"
|
||||
[run.model]
|
||||
name = "project-model"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
r#"
|
||||
[llm]
|
||||
_version = 1
|
||||
|
||||
[run.model]
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
user_only = "1"
|
||||
shared = "user"
|
||||
"#,
|
||||
),
|
||||
),
|
||||
|
|
@ -232,66 +283,47 @@ shared = "user"
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = settings.llm.expect("llm config");
|
||||
assert_eq!(settings.goal.as_deref(), Some("workflow goal"));
|
||||
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("workflow_only")),
|
||||
Some(&"1".to_string())
|
||||
settings.run_goal_inline_str().as_deref(),
|
||||
Some("workflow goal")
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("project_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("user_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
|
||||
Some(&"workflow".to_string())
|
||||
settings.run_model_name_str().as_deref(),
|
||||
Some("workflow-model")
|
||||
);
|
||||
assert_eq!(settings.run_model_provider_str().as_deref(), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_server_mode_merges_server_defaults_without_allowing_server_owned_local_overrides() {
|
||||
let server_settings: fabro_types::Settings = toml::from_str(
|
||||
r#"
|
||||
storage_dir = "/srv/fabro"
|
||||
max_concurrent_runs = 9
|
||||
dry_run = true
|
||||
fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() {
|
||||
let mut server_settings = fabro_types::settings::SettingsFile::default();
|
||||
server_settings.server = Some(ServerLayer {
|
||||
storage: Some(ServerStorageLayer {
|
||||
root: Some(InterpString::parse("/srv/fabro")),
|
||||
}),
|
||||
scheduler: Some(ServerSchedulerLayer {
|
||||
max_concurrent_runs: Some(9),
|
||||
}),
|
||||
..ServerLayer::default()
|
||||
});
|
||||
|
||||
[vars]
|
||||
server_only = "1"
|
||||
shared = "server"
|
||||
let project_with_server = layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run]
|
||||
goal = "project goal"
|
||||
|
||||
[server.storage]
|
||||
root = "/tmp/should-be-inert"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let settings = resolve_settings(
|
||||
EffectiveSettingsLayers::new(
|
||||
ConfigLayer::default(),
|
||||
ConfigLayer::default(),
|
||||
layer(
|
||||
r#"
|
||||
storage_dir = "/tmp/local-storage"
|
||||
max_concurrent_runs = 3
|
||||
|
||||
[vars]
|
||||
project_only = "1"
|
||||
shared = "project"
|
||||
"#,
|
||||
),
|
||||
project_with_server,
|
||||
ConfigLayer::default(),
|
||||
),
|
||||
Some(&server_settings),
|
||||
|
|
@ -299,130 +331,28 @@ shared = "project"
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
|
||||
assert_eq!(settings.max_concurrent_runs, Some(9));
|
||||
assert_eq!(settings.dry_run, None);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("server_only")),
|
||||
Some(&"1".to_string())
|
||||
settings.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro")
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("project_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
|
||||
Some(&"project".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_server_mode_merges_workflow_project_user_and_server_layers() {
|
||||
let server_settings: fabro_types::Settings = toml::from_str(
|
||||
r#"
|
||||
storage_dir = "/srv/fabro"
|
||||
|
||||
[vars]
|
||||
server_only = "1"
|
||||
shared = "server"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = resolve_settings(
|
||||
EffectiveSettingsLayers::new(
|
||||
ConfigLayer::default(),
|
||||
layer(
|
||||
r#"
|
||||
[llm]
|
||||
model = "workflow-model"
|
||||
|
||||
[vars]
|
||||
workflow_only = "1"
|
||||
shared = "workflow"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
r#"
|
||||
[vars]
|
||||
project_only = "1"
|
||||
shared = "project"
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
r#"
|
||||
[llm]
|
||||
provider = "openai"
|
||||
|
||||
[vars]
|
||||
user_only = "1"
|
||||
"#,
|
||||
),
|
||||
),
|
||||
Some(&server_settings),
|
||||
EffectiveSettingsMode::RemoteServer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = settings.llm.expect("llm config");
|
||||
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
|
||||
assert_eq!(llm.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("workflow_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("project_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("user_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.vars
|
||||
.as_ref()
|
||||
.and_then(|vars| vars.get("server_only")),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
|
||||
Some(&"workflow".to_string())
|
||||
settings.run_goal_inline_str().as_deref(),
|
||||
Some("project goal")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_daemon_mode_only_applies_server_owned_overrides() {
|
||||
let server_settings: fabro_types::Settings = toml::from_str(
|
||||
r#"
|
||||
storage_dir = "/srv/fabro"
|
||||
max_concurrent_runs = 7
|
||||
|
||||
[llm]
|
||||
model = "server-model"
|
||||
|
||||
[vars]
|
||||
server_only = "1"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut server_settings = fabro_types::settings::SettingsFile::default();
|
||||
server_settings.server = Some(ServerLayer {
|
||||
storage: Some(ServerStorageLayer {
|
||||
root: Some(InterpString::parse("/srv/fabro")),
|
||||
}),
|
||||
scheduler: Some(ServerSchedulerLayer {
|
||||
max_concurrent_runs: Some(7),
|
||||
}),
|
||||
..ServerLayer::default()
|
||||
});
|
||||
|
||||
let settings = resolve_settings(
|
||||
EffectiveSettingsLayers::default(),
|
||||
|
|
@ -431,9 +361,10 @@ server_only = "1"
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
|
||||
assert_eq!(settings.max_concurrent_runs, Some(7));
|
||||
assert_eq!(settings.llm, None);
|
||||
assert_eq!(settings.vars, None);
|
||||
assert_eq!(
|
||||
settings.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro")
|
||||
);
|
||||
assert_eq!(settings.max_concurrent_runs(), Some(7));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
pub use fabro_types::settings::hook::*;
|
||||
|
|
@ -1,30 +1,31 @@
|
|||
extern crate self as fabro_config;
|
||||
|
||||
pub mod combine;
|
||||
pub mod config;
|
||||
pub mod effective_settings;
|
||||
pub mod home;
|
||||
pub mod hook;
|
||||
pub mod legacy_env;
|
||||
pub mod mcp;
|
||||
pub mod merge;
|
||||
pub mod project;
|
||||
pub mod run;
|
||||
pub mod sandbox;
|
||||
pub mod server;
|
||||
pub mod settings;
|
||||
pub mod storage;
|
||||
pub mod user;
|
||||
|
||||
pub use config::ConfigLayer;
|
||||
pub use fabro_types::Combine;
|
||||
pub use fabro_util::path::expand_tilde;
|
||||
pub use home::Home;
|
||||
pub use storage::{RunScratch, ServerState, Storage};
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Resolve the storage directory: v2 `server.storage.root` > home default.
|
||||
#[must_use]
|
||||
pub fn resolve_storage_dir(settings: &SettingsFile) -> PathBuf {
|
||||
settings.storage_dir()
|
||||
}
|
||||
|
||||
/// Load a TOML config from an explicit path or `~/.fabro/{filename}`.
|
||||
///
|
||||
/// Returns `T::default()` when no explicit path is given and the default file
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
pub use fabro_types::settings::mcp::*;
|
||||
683
lib/crates/fabro-config/src/merge.rs
Normal file
683
lib/crates/fabro-config/src/merge.rs
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
//! v2 merge matrix implementation.
|
||||
//!
|
||||
//! Encodes the normative merge behavior from the requirements doc: replace
|
||||
//! scalars, field-merge structured tables, replace freeform maps by default,
|
||||
//! sticky merge-by-key where the requirements call for it, splice-capable
|
||||
//! string arrays, whole-list replacement for ordered prepare steps, and
|
||||
//! ordered hook merging with optional `id` replacement.
|
||||
#![allow(clippy::needless_pass_by_value)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::settings::cli::{
|
||||
CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliTargetLayer,
|
||||
};
|
||||
use fabro_types::settings::project::ProjectLayer;
|
||||
use fabro_types::settings::run::{
|
||||
DaytonaSandboxLayer, GitAuthorLayer, HookEntry, InterviewsLayer, ModelRefOrSplice,
|
||||
NotificationRouteLayer, RunAgentLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer,
|
||||
RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer,
|
||||
StringOrSplice,
|
||||
};
|
||||
use fabro_types::settings::server::{
|
||||
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
|
||||
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
|
||||
};
|
||||
use fabro_types::settings::tree::SettingsFile;
|
||||
use fabro_types::settings::workflow::WorkflowLayer;
|
||||
|
||||
/// Combine two settings files: `higher` takes precedence over `lower` wherever
|
||||
/// the merge matrix does not dictate otherwise.
|
||||
#[must_use]
|
||||
pub fn combine_files(lower: SettingsFile, higher: SettingsFile) -> SettingsFile {
|
||||
SettingsFile {
|
||||
version: higher.version.or(lower.version),
|
||||
project: merge_option(lower.project, higher.project, combine_project),
|
||||
workflow: merge_option(lower.workflow, higher.workflow, combine_workflow),
|
||||
run: merge_option(lower.run, higher.run, combine_run),
|
||||
cli: merge_option(lower.cli, higher.cli, combine_cli),
|
||||
server: merge_option(lower.server, higher.server, combine_server),
|
||||
features: replace_if_some(lower.features, higher.features),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_option<T>(lower: Option<T>, higher: Option<T>, f: fn(T, T) -> T) -> Option<T> {
|
||||
match (lower, higher) {
|
||||
(Some(l), Some(h)) => Some(f(l, h)),
|
||||
(Some(l), None) => Some(l),
|
||||
(None, Some(h)) => Some(h),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_if_some<T>(lower: Option<T>, higher: Option<T>) -> Option<T> {
|
||||
higher.or(lower)
|
||||
}
|
||||
|
||||
fn merge_string_map_replace(
|
||||
lower: HashMap<String, String>,
|
||||
higher: HashMap<String, String>,
|
||||
) -> HashMap<String, String> {
|
||||
if higher.is_empty() { lower } else { higher }
|
||||
}
|
||||
|
||||
fn merge_string_map_sticky<T>(
|
||||
mut lower: HashMap<String, T>,
|
||||
higher: HashMap<String, T>,
|
||||
) -> HashMap<String, T> {
|
||||
for (k, v) in higher {
|
||||
lower.insert(k, v);
|
||||
}
|
||||
lower
|
||||
}
|
||||
|
||||
// ------------------- project -------------------
|
||||
|
||||
fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer {
|
||||
ProjectLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
description: higher.description.or(lower.description),
|
||||
directory: higher.directory.or(lower.directory),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- workflow -------------------
|
||||
|
||||
fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer {
|
||||
WorkflowLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
description: higher.description.or(lower.description),
|
||||
graph: higher.graph.or(lower.graph),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- run -------------------
|
||||
|
||||
fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer {
|
||||
RunLayer {
|
||||
goal: higher.goal.or(lower.goal),
|
||||
working_dir: higher.working_dir.or(lower.working_dir),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
inputs: higher.inputs.or(lower.inputs),
|
||||
model: merge_option(lower.model, higher.model, combine_run_model),
|
||||
git: merge_option(lower.git, higher.git, combine_run_git),
|
||||
prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare),
|
||||
execution: merge_option(lower.execution, higher.execution, combine_run_execution),
|
||||
checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint),
|
||||
sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox),
|
||||
notifications: combine_notifications(lower.notifications, higher.notifications),
|
||||
interviews: merge_option(lower.interviews, higher.interviews, combine_interviews),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_run_agent),
|
||||
hooks: combine_hooks(lower.hooks, higher.hooks),
|
||||
scm: merge_option(lower.scm, higher.scm, combine_run_scm),
|
||||
pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr),
|
||||
artifacts: replace_if_some(lower.artifacts, higher.artifacts),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer {
|
||||
RunModelLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
name: higher.name.or(lower.name),
|
||||
fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks),
|
||||
}
|
||||
}
|
||||
|
||||
fn splice_model_fallbacks(
|
||||
lower: Vec<ModelRefOrSplice>,
|
||||
higher: Vec<ModelRefOrSplice>,
|
||||
) -> Vec<ModelRefOrSplice> {
|
||||
if higher.is_empty() {
|
||||
return lower;
|
||||
}
|
||||
let splice_pos = higher
|
||||
.iter()
|
||||
.position(|e| matches!(e, ModelRefOrSplice::Splice));
|
||||
let Some(pos) = splice_pos else {
|
||||
return higher;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (i, entry) in higher.into_iter().enumerate() {
|
||||
if i == pos {
|
||||
out.extend(
|
||||
lower
|
||||
.iter()
|
||||
.filter(|e| !matches!(e, ModelRefOrSplice::Splice))
|
||||
.cloned(),
|
||||
);
|
||||
} else if !matches!(entry, ModelRefOrSplice::Splice) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer {
|
||||
RunGitLayer {
|
||||
author: merge_option(lower.author, higher.author, combine_git_author),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer {
|
||||
GitAuthorLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
email: higher.email.or(lower.email),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunPrepareLayer {
|
||||
// Whole-list replacement for prepare.steps per the merge matrix.
|
||||
higher
|
||||
}
|
||||
|
||||
fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer {
|
||||
RunExecutionLayer {
|
||||
mode: higher.mode.or(lower.mode),
|
||||
approval: higher.approval.or(lower.approval),
|
||||
retros: higher.retros.or(lower.retros),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_checkpoint(
|
||||
lower: RunCheckpointLayer,
|
||||
higher: RunCheckpointLayer,
|
||||
) -> RunCheckpointLayer {
|
||||
// Exclude globs are a security/policy list: replace by default.
|
||||
if higher.exclude_globs.is_empty() {
|
||||
lower
|
||||
} else {
|
||||
higher
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer {
|
||||
RunSandboxLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
preserve: higher.preserve.or(lower.preserve),
|
||||
devcontainer: higher.devcontainer.or(lower.devcontainer),
|
||||
// Sticky merge-by-key for run.sandbox.env per R71.
|
||||
env: merge_string_map_sticky(lower.env, higher.env),
|
||||
local: higher.local.or(lower.local),
|
||||
daytona: merge_option(lower.daytona, higher.daytona, combine_daytona),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> DaytonaSandboxLayer {
|
||||
DaytonaSandboxLayer {
|
||||
auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval),
|
||||
// Sticky merge-by-key for provider-native labels per R71.
|
||||
labels: merge_string_map_sticky(lower.labels, higher.labels),
|
||||
snapshot: higher.snapshot.or(lower.snapshot),
|
||||
network: higher.network.or(lower.network),
|
||||
skip_clone: higher.skip_clone.or(lower.skip_clone),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_notifications(
|
||||
mut lower: HashMap<String, NotificationRouteLayer>,
|
||||
higher: HashMap<String, NotificationRouteLayer>,
|
||||
) -> HashMap<String, NotificationRouteLayer> {
|
||||
for (k, h) in higher {
|
||||
match lower.remove(&k) {
|
||||
Some(l) => {
|
||||
lower.insert(k, combine_notification_route(l, h));
|
||||
}
|
||||
None => {
|
||||
lower.insert(k, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
lower
|
||||
}
|
||||
|
||||
fn combine_notification_route(
|
||||
lower: NotificationRouteLayer,
|
||||
higher: NotificationRouteLayer,
|
||||
) -> NotificationRouteLayer {
|
||||
NotificationRouteLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
events: splice_events(lower.events, higher.events),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
fn splice_events(lower: Vec<StringOrSplice>, higher: Vec<StringOrSplice>) -> Vec<StringOrSplice> {
|
||||
if higher.is_empty() {
|
||||
return lower;
|
||||
}
|
||||
let splice_pos = higher
|
||||
.iter()
|
||||
.position(|e| matches!(e, StringOrSplice::Splice));
|
||||
let Some(pos) = splice_pos else {
|
||||
return higher;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (i, entry) in higher.into_iter().enumerate() {
|
||||
if i == pos {
|
||||
out.extend(
|
||||
lower
|
||||
.iter()
|
||||
.filter(|e| !matches!(e, StringOrSplice::Splice))
|
||||
.cloned(),
|
||||
);
|
||||
} else if !matches!(entry, StringOrSplice::Splice) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer {
|
||||
InterviewsLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLayer {
|
||||
RunAgentLayer {
|
||||
permissions: higher.permissions.or(lower.permissions),
|
||||
// MCP entries: field-merge per key. Higher replaces lower for same keys.
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two ordered hook lists using the id-aware replacement rule.
|
||||
fn combine_hooks(lower: Vec<HookEntry>, higher: Vec<HookEntry>) -> Vec<HookEntry> {
|
||||
let mut out: Vec<HookEntry> = Vec::with_capacity(lower.len() + higher.len());
|
||||
let mut appended_ids: Vec<String> = Vec::new();
|
||||
|
||||
for lower_entry in &lower {
|
||||
if let Some(id) = &lower_entry.id {
|
||||
if let Some(replacement) = higher.iter().find(|h| h.id.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
out.push(replacement.clone());
|
||||
appended_ids.push(id.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(lower_entry.clone());
|
||||
}
|
||||
|
||||
for higher_entry in higher {
|
||||
if let Some(id) = &higher_entry.id {
|
||||
if appended_ids.contains(id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(higher_entry);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn combine_run_scm(lower: RunScmLayer, higher: RunScmLayer) -> RunScmLayer {
|
||||
RunScmLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
owner: higher.owner.or(lower.owner),
|
||||
repository: higher.repository.or(lower.repository),
|
||||
github: higher.github.or(lower.github),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer {
|
||||
RunPullRequestLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
draft: higher.draft.or(lower.draft),
|
||||
auto_merge: higher.auto_merge.or(lower.auto_merge),
|
||||
merge_strategy: higher.merge_strategy.or(lower.merge_strategy),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- cli -------------------
|
||||
|
||||
fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer {
|
||||
CliLayer {
|
||||
target: merge_option(lower.target, higher.target, combine_cli_target),
|
||||
auth: higher.auth.or(lower.auth),
|
||||
exec: merge_option(lower.exec, higher.exec, combine_cli_exec),
|
||||
output: higher.output.or(lower.output),
|
||||
updates: higher.updates.or(lower.updates),
|
||||
logging: higher.logging.or(lower.logging),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTargetLayer {
|
||||
// The transport type is a scalar discriminant: the higher layer's choice wins.
|
||||
higher
|
||||
}
|
||||
|
||||
fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer {
|
||||
CliExecLayer {
|
||||
prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep),
|
||||
model: merge_option(lower.model, higher.model, combine_cli_exec_model),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_cli_exec_model(
|
||||
lower: CliExecModelLayer,
|
||||
higher: CliExecModelLayer,
|
||||
) -> CliExecModelLayer {
|
||||
CliExecModelLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
name: higher.name.or(lower.name),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_cli_exec_agent(
|
||||
lower: CliExecAgentLayer,
|
||||
higher: CliExecAgentLayer,
|
||||
) -> CliExecAgentLayer {
|
||||
CliExecAgentLayer {
|
||||
permissions: higher.permissions.or(lower.permissions),
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- server -------------------
|
||||
|
||||
fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer {
|
||||
ServerLayer {
|
||||
listen: merge_option(lower.listen, higher.listen, combine_listen),
|
||||
api: higher.api.or(lower.api),
|
||||
web: merge_option(lower.web, higher.web, combine_server_web),
|
||||
auth: merge_option(lower.auth, higher.auth, combine_server_auth),
|
||||
storage: merge_option(lower.storage, higher.storage, combine_server_storage),
|
||||
artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts),
|
||||
slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb),
|
||||
scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler),
|
||||
logging: higher.logging.or(lower.logging),
|
||||
integrations: merge_option(
|
||||
lower.integrations,
|
||||
higher.integrations,
|
||||
combine_server_integrations,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> ServerListenLayer {
|
||||
// Transport type is a scalar discriminant: replace whole.
|
||||
higher
|
||||
}
|
||||
|
||||
fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer {
|
||||
ServerWebLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
url: higher.url.or(lower.url),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_auth(lower: ServerAuthLayer, higher: ServerAuthLayer) -> ServerAuthLayer {
|
||||
ServerAuthLayer {
|
||||
api: higher.api.or(lower.api),
|
||||
web: higher.web.or(lower.web),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_storage(
|
||||
lower: ServerStorageLayer,
|
||||
higher: ServerStorageLayer,
|
||||
) -> ServerStorageLayer {
|
||||
ServerStorageLayer {
|
||||
root: higher.root.or(lower.root),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_artifacts(
|
||||
lower: ServerArtifactsLayer,
|
||||
higher: ServerArtifactsLayer,
|
||||
) -> ServerArtifactsLayer {
|
||||
ServerArtifactsLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_slatedb(
|
||||
lower: ServerSlateDbLayer,
|
||||
higher: ServerSlateDbLayer,
|
||||
) -> ServerSlateDbLayer {
|
||||
ServerSlateDbLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
flush_interval: higher.flush_interval.or(lower.flush_interval),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_scheduler(
|
||||
lower: ServerSchedulerLayer,
|
||||
higher: ServerSchedulerLayer,
|
||||
) -> ServerSchedulerLayer {
|
||||
ServerSchedulerLayer {
|
||||
max_concurrent_runs: higher.max_concurrent_runs.or(lower.max_concurrent_runs),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_server_integrations(
|
||||
lower: ServerIntegrationsLayer,
|
||||
higher: ServerIntegrationsLayer,
|
||||
) -> ServerIntegrationsLayer {
|
||||
ServerIntegrationsLayer {
|
||||
github: higher.github.or(lower.github),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::{InterpString, parse_settings_file};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse(input: &str) -> SettingsFile {
|
||||
parse_settings_file(input).expect("fixture should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_inputs_replace_wholesale() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[run.inputs]
|
||||
a = "lower"
|
||||
b = "lower"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[run.inputs]
|
||||
a = "higher"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let inputs = merged.run.unwrap().inputs.unwrap();
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into())));
|
||||
assert!(!inputs.contains_key("b"), "lower key should be gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_sandbox_env_merges_sticky() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[run.sandbox.env]
|
||||
A = "lower-a"
|
||||
B = "lower-b"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[run.sandbox.env]
|
||||
A = "higher-a"
|
||||
C = "higher-c"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let sandbox = merged.run.unwrap().sandbox.unwrap();
|
||||
assert_eq!(sandbox.env.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_prepare_steps_replaces_whole_list() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[[run.prepare.steps]]
|
||||
script = "lower-1"
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "lower-2"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[[run.prepare.steps]]
|
||||
script = "higher-1"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let steps = merged.run.unwrap().prepare.unwrap().steps;
|
||||
assert_eq!(steps.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_model_fallbacks_splice_inserts_inherited() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[run.model]
|
||||
fallbacks = ["openai", "gpt-5.4"]
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[run.model]
|
||||
fallbacks = ["anthropic", "..."]
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let fallbacks = merged.run.unwrap().model.unwrap().fallbacks;
|
||||
// ["anthropic", "openai", "gpt-5.4"]
|
||||
assert_eq!(fallbacks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_replace_by_id_in_place() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[[run.hooks]]
|
||||
id = "shared"
|
||||
event = "run_start"
|
||||
script = "lower-script"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[[run.hooks]]
|
||||
id = "shared"
|
||||
event = "run_start"
|
||||
script = "higher-script"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let hooks = merged.run.unwrap().hooks;
|
||||
assert_eq!(hooks.len(), 1);
|
||||
assert_eq!(
|
||||
hooks[0]
|
||||
.script
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("higher-script")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_hooks_append_after_merged_inherited() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[[run.hooks]]
|
||||
event = "run_start"
|
||||
script = "lower-anon"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[[run.hooks]]
|
||||
event = "run_complete"
|
||||
script = "higher-anon"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let hooks = merged.run.unwrap().hooks;
|
||||
assert_eq!(hooks.len(), 2);
|
||||
assert_eq!(
|
||||
hooks[0]
|
||||
.script
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("lower-anon")
|
||||
);
|
||||
assert_eq!(
|
||||
hooks[1]
|
||||
.script
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.as_deref(),
|
||||
Some("higher-anon")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_route_events_splice() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[run.notifications.ops]
|
||||
events = ["run.failed"]
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[run.notifications.ops]
|
||||
events = ["...", "run.completed"]
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let run = merged.run.unwrap();
|
||||
let events = &run.notifications["ops"].events;
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_metadata_replaces_wholesale() {
|
||||
let lower = parse(
|
||||
r#"
|
||||
[project.metadata]
|
||||
a = "1"
|
||||
b = "2"
|
||||
"#,
|
||||
);
|
||||
let higher = parse(
|
||||
r#"
|
||||
[project.metadata]
|
||||
a = "replaced"
|
||||
"#,
|
||||
);
|
||||
let merged = combine_files(lower, higher);
|
||||
let meta = merged.project.unwrap().metadata;
|
||||
assert_eq!(meta.len(), 1);
|
||||
assert_eq!(meta.get("a"), Some(&"replaced".to_string()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
//! Project-level config loading and workflow discovery.
|
||||
//!
|
||||
//! Stage 3 replaced the parse-time `ProjectConfig` type with the v2 parse
|
||||
//! tree in `fabro_types::settings::v2`. This module keeps the workflow
|
||||
//! discovery helpers and re-exports resolved project settings.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::ConfigLayer;
|
||||
use crate::run;
|
||||
use fabro_types::Settings;
|
||||
pub use fabro_types::settings::project::ProjectSettings;
|
||||
use fabro_types::settings::{InterpString, SettingsFile};
|
||||
|
||||
const CONFIG_FILENAME: &str = "fabro.toml";
|
||||
const SUPPORTED_VERSION: u32 = 1;
|
||||
const RUN_GRAPH_FILE: &str = "workflow.fabro";
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ProjectConfig {
|
||||
pub root: Option<String>,
|
||||
}
|
||||
const DEFAULT_FABRO_DIRECTORY: &str = "fabro/";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkflowPathResolution {
|
||||
|
|
@ -27,40 +27,23 @@ pub struct WorkflowPathResolution {
|
|||
pub workflow_slug: Option<String>,
|
||||
}
|
||||
|
||||
fn default_root() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
impl From<ProjectConfig> for ProjectSettings {
|
||||
fn from(value: ProjectConfig) -> Self {
|
||||
Self {
|
||||
root: value.root.unwrap_or_else(default_root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a project config from a TOML string.
|
||||
pub fn parse_project_config(content: &str) -> anyhow::Result<ConfigLayer> {
|
||||
let config: ConfigLayer = toml::from_str(content).context("Failed to parse project config")?;
|
||||
let version = config.version.unwrap_or(0);
|
||||
if version != SUPPORTED_VERSION {
|
||||
bail!(
|
||||
"Unsupported project config version: {version}. Only version {SUPPORTED_VERSION} is supported.",
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
ConfigLayer::parse(content).context("Failed to parse project config")
|
||||
}
|
||||
|
||||
/// Load a project config from a file path.
|
||||
///
|
||||
/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file`
|
||||
/// paths are anchored at the directory of `path` at load time.
|
||||
pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let config = parse_project_config(&content)?;
|
||||
let config = ConfigLayer::load(path).context("Failed to parse project config")?;
|
||||
let root = config
|
||||
.fabro
|
||||
.as_v2()
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|f| f.root.as_deref())
|
||||
.unwrap_or(".");
|
||||
.and_then(|p| p.directory.as_deref())
|
||||
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
|
||||
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
|
||||
Ok(config)
|
||||
}
|
||||
|
|
@ -98,11 +81,6 @@ fn workflow_slug_from_path(workflow_path: &Path) -> Option<String> {
|
|||
}
|
||||
|
||||
/// Resolve a workflow argument to a path.
|
||||
///
|
||||
/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is.
|
||||
/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve
|
||||
/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions
|
||||
/// if an `fabro.toml` exists but the workflow wasn't found.
|
||||
pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result<PathBuf> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
resolve_workflow_arg_from(arg, &start)
|
||||
|
|
@ -117,8 +95,13 @@ pub fn resolve_workflow_path(
|
|||
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||
match run::load_run_config(&path) {
|
||||
Ok(cfg) => {
|
||||
let dot_path =
|
||||
run::resolve_graph_path(&path, cfg.graph.as_deref().unwrap_or(RUN_GRAPH_FILE));
|
||||
let graph = cfg
|
||||
.as_v2()
|
||||
.workflow
|
||||
.as_ref()
|
||||
.and_then(|w| w.graph.as_deref())
|
||||
.unwrap_or(RUN_GRAPH_FILE);
|
||||
let dot_path = run::resolve_graph_path(&path, graph);
|
||||
Ok(WorkflowPathResolution {
|
||||
resolved_workflow_path: path.clone(),
|
||||
dot_path,
|
||||
|
|
@ -141,11 +124,11 @@ pub fn resolve_workflow_path(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn resolve_working_directory(settings: &Settings, caller_cwd: &Path) -> PathBuf {
|
||||
let Some(work_dir) = settings.work_dir.as_deref() else {
|
||||
pub fn resolve_working_directory(settings: &SettingsFile, caller_cwd: &Path) -> PathBuf {
|
||||
let Some(work_dir) = settings.run_working_dir().map(InterpString::as_source) else {
|
||||
return caller_cwd.to_path_buf();
|
||||
};
|
||||
let path = PathBuf::from(work_dir);
|
||||
let path = PathBuf::from(&work_dir);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
|
|
@ -275,11 +258,16 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Read the `goal` field from a `workflow.toml` without full config validation.
|
||||
/// Read the `run.goal` field from a `workflow.toml` without full config validation.
|
||||
fn read_workflow_goal(workflow_toml: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(workflow_toml).ok()?;
|
||||
let table: toml::Table = content.parse().ok()?;
|
||||
table.get("goal")?.as_str().map(String::from)
|
||||
table
|
||||
.get("run")?
|
||||
.as_table()?
|
||||
.get("goal")?
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// List workflows with metadata by scanning project and user workflow directories.
|
||||
|
|
@ -320,7 +308,6 @@ pub fn list_workflows_detailed(
|
|||
}
|
||||
|
||||
/// List workflow names by scanning project and user workflow directories.
|
||||
/// Project workflows appear first; user workflows are deduplicated.
|
||||
pub fn list_available_workflows(
|
||||
project_workflows_dir: Option<&Path>,
|
||||
user_workflows_dir: Option<&Path>,
|
||||
|
|
@ -353,9 +340,6 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
|
|||
}
|
||||
|
||||
/// Resolve a workflow argument to a DOT path and optional run config.
|
||||
///
|
||||
/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file,
|
||||
/// loads the run config and resolves the graph path within it.
|
||||
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<ConfigLayer>)> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let resolution = resolve_workflow_path(arg, &start)?;
|
||||
|
|
@ -363,147 +347,114 @@ pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<ConfigLay
|
|||
}
|
||||
|
||||
/// Check whether retros are enabled in the project config.
|
||||
/// Returns `false` (the default) if no config is found or on error.
|
||||
/// Retros are an experimental feature gated behind `[features] retros = true`.
|
||||
/// Retros are now expressed as `[run.execution] retros = true` in v2.
|
||||
pub fn is_retro_enabled() -> bool {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
match discover_project_config(&start) {
|
||||
Ok(Some((_path, config))) => config
|
||||
.features
|
||||
.as_v2()
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|f| f.retros)
|
||||
.and_then(|r| r.execution.as_ref())
|
||||
.and_then(|e| e.retros)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the fabro root directory from a config file path and its config.
|
||||
/// The returned path is the directory containing `fabro.toml` joined with the `root` value.
|
||||
/// The returned path is the directory containing `fabro.toml` joined with the
|
||||
/// `project.directory` value (default: `fabro/`).
|
||||
pub fn resolve_fabro_root(config_path: &Path, config: &ConfigLayer) -> PathBuf {
|
||||
let project_dir = config_path
|
||||
.parent()
|
||||
.expect("config_path should have a parent directory");
|
||||
let root = config
|
||||
.fabro
|
||||
.as_v2()
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|f| f.root.as_deref())
|
||||
.unwrap_or(".");
|
||||
.and_then(|p| p.directory.as_deref())
|
||||
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
|
||||
project_dir.join(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::run::{LlmConfig, PullRequestConfig};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert_eq!(config.version, Some(1));
|
||||
assert_eq!(config.fabro, None,);
|
||||
let config = parse_project_config("_version = 1\n").unwrap();
|
||||
assert_eq!(config.as_v2().version, Some(1));
|
||||
assert!(config.as_v2().project.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_config() {
|
||||
let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap();
|
||||
assert_eq!(config.fabro.unwrap().root.as_deref(), Some("fabro/"));
|
||||
}
|
||||
fn parse_with_project_directory() {
|
||||
let config = parse_project_config(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
#[test]
|
||||
fn parse_retros_default_false() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert!(
|
||||
!config
|
||||
.features
|
||||
[project]
|
||||
directory = "fabro/"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config
|
||||
.as_v2()
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|f| f.retros)
|
||||
.unwrap_or(false)
|
||||
.and_then(|p| p.directory.as_deref()),
|
||||
Some("fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retros_enabled() {
|
||||
let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap();
|
||||
assert_eq!(config.features.unwrap().retros, Some(true));
|
||||
fn parse_with_run_execution_retros() {
|
||||
let config = parse_project_config(
|
||||
"
|
||||
_version = 1
|
||||
|
||||
[run.execution]
|
||||
retros = true
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config
|
||||
.as_v2()
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|r| r.execution.as_ref())
|
||||
.and_then(|e| e.retros),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_mismatch() {
|
||||
let err = parse_project_config("version = 2\n").unwrap_err();
|
||||
fn parse_rejects_legacy_llm_section() {
|
||||
let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err();
|
||||
let text = format!("{err:#}");
|
||||
assert!(
|
||||
err.to_string().contains("Unsupported"),
|
||||
"Expected 'Unsupported' in error, got: {err}"
|
||||
text.contains("run.model") || text.contains("llm"),
|
||||
"expected rename hint for [llm]: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_request_config() {
|
||||
let config =
|
||||
parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.pull_request,
|
||||
Some(PullRequestConfig {
|
||||
enabled: Some(true),
|
||||
draft: Some(false),
|
||||
auto_merge: None,
|
||||
merge_strategy: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_sandbox() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "my-snapshot"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
|
||||
let snap = sandbox.daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(snap.name.as_deref(), Some("my-snapshot"));
|
||||
assert_eq!(snap.cpu, Some(4));
|
||||
assert_eq!(snap.memory, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_hooks_and_mcp() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo start"
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
command = ["npx", "@playwright/mcp@latest"]
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
assert_eq!(config.mcp_servers.len(), 1);
|
||||
assert!(config.mcp_servers.contains_key("playwright"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_llm_and_work_dir() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
work_dir = "/workspace"
|
||||
[llm]
|
||||
model = "claude-sonnet-4-6"
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.work_dir.as_deref(), Some("/workspace"));
|
||||
assert_eq!(
|
||||
config.llm.unwrap().model.as_deref(),
|
||||
Some("claude-sonnet-4-6")
|
||||
fn parse_higher_version_errors() {
|
||||
let err = parse_project_config("_version = 2\n").unwrap_err();
|
||||
let chain: String = err
|
||||
.chain()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
assert!(
|
||||
chain.contains("Upgrade") || chain.to_lowercase().contains("version"),
|
||||
"Expected version hint in chain: {chain}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -511,224 +462,44 @@ model = "claude-sonnet-4-6"
|
|||
fn load_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("fabro.toml");
|
||||
fs::write(&path, "version = 1\n").unwrap();
|
||||
fs::write(&path, "_version = 1\n").unwrap();
|
||||
let config = load_project_config(&path).unwrap();
|
||||
assert_eq!(config.version, Some(1));
|
||||
assert_eq!(config.as_v2().version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_walks_ancestors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "_version = 1\n").unwrap();
|
||||
let sub = tmp.path().join("sub").join("dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
|
||||
assert_eq!(found_path, tmp.path().join("fabro.toml"));
|
||||
assert_eq!(config.version, Some(1));
|
||||
assert_eq!(config.as_v2().version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_absent() {
|
||||
fn load_project_config_rewrites_relative_goal_file_path() {
|
||||
use fabro_types::settings::run::RunGoalLayer;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = discover_project_config(tmp.path()).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
let path = tmp.path().join("fabro.toml");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"_version = 1
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_subdirectory() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ConfigLayer {
|
||||
version: Some(1),
|
||||
fabro: Some(ProjectConfig {
|
||||
root: Some("fabro/".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_project_config(&path).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_dot() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ConfigLayer {
|
||||
version: Some(1),
|
||||
fabro: Some(ProjectConfig {
|
||||
root: Some(".".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_without_fabro_section() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ConfigLayer::default();
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_workflow_discovers_project_from_workflow_location() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_dir = tmp.path().join("project");
|
||||
let other_dir = tmp.path().join("other");
|
||||
let workflow_dir = project_dir.join("workflows").join("demo");
|
||||
fs::create_dir_all(&workflow_dir).unwrap();
|
||||
fs::create_dir_all(&other_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
project_dir.join("fabro.toml"),
|
||||
"version = 1\nverbose = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
other_dir.join("fabro.toml"),
|
||||
"version = 1\nverbose = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap();
|
||||
|
||||
let layer =
|
||||
ConfigLayer::for_workflow(&workflow_dir.join("workflow.toml"), &other_dir).unwrap();
|
||||
|
||||
assert_eq!(layer.verbose, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_resolve_preserves_precedence_order() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_dir = tmp.path().join("project");
|
||||
let workflow_dir = project_dir.join("workflows").join("demo");
|
||||
fs::create_dir_all(&workflow_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
project_dir.join("fabro.toml"),
|
||||
"version = 1\nverbose = true\n[llm]\nmodel = \"project-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
"version = 1\ndry_run = true\n[llm]\nmodel = \"workflow-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cli_defaults = ConfigLayer {
|
||||
verbose: Some(false),
|
||||
llm: Some(LlmConfig {
|
||||
model: Some("cli-model".to_string()),
|
||||
provider: None,
|
||||
fallbacks: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let overrides = ConfigLayer {
|
||||
dry_run: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let settings = overrides
|
||||
.combine(
|
||||
ConfigLayer::for_workflow(
|
||||
&workflow_dir.join("workflow.toml"),
|
||||
project_dir.as_path(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.combine(cli_defaults)
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
settings.llm.as_ref().and_then(|llm| llm.model.as_deref()),
|
||||
Some("workflow-model")
|
||||
);
|
||||
assert_eq!(settings.dry_run, Some(false));
|
||||
assert_eq!(settings.verbose, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_toml_extension_resolves_relative_to_start_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap();
|
||||
assert_eq!(result, tmp.path().join("my-workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_fabro_extension_resolves_relative_to_start_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap();
|
||||
assert_eq!(result, tmp.path().join("my-workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_absolute_extension_preserves_absolute_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("my-workflow.toml");
|
||||
let result = resolve_workflow_arg_from(&path, Path::new("/tmp")).unwrap();
|
||||
assert_eq!(result, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_no_config_returns_literal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("my-workflow");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, wf_dir.join("workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_typo_suggests_similar_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("implement");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"w.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}");
|
||||
assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read" }
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let github = config.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
let expected = tmp.path().join("prompts").join("goal.md");
|
||||
assert_eq!(file.as_source(), expected.to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,223 +1,86 @@
|
|||
use std::collections::HashMap;
|
||||
//! Workflow / run config loading helpers.
|
||||
//!
|
||||
//! Thin wrappers around `ConfigLayer::parse` / `ConfigLayer::load` plus
|
||||
//! path resolution for the `[workflow] graph` override. Runtime types
|
||||
//! that used to be re-exported from here live under
|
||||
//! `fabro_types::settings::run` now.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::combine::Combine;
|
||||
use crate::config::ConfigLayer;
|
||||
use crate::sandbox::DockerfileSource;
|
||||
pub use fabro_types::settings::run::{
|
||||
ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
|
||||
PullRequestSettings, SetupSettings,
|
||||
};
|
||||
|
||||
const SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct CheckpointConfig {
|
||||
#[serde(default)]
|
||||
pub exclude_globs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Combine for CheckpointConfig {
|
||||
fn combine(mut self, other: Self) -> Self {
|
||||
self.exclude_globs.extend(other.exclude_globs);
|
||||
self.exclude_globs.sort();
|
||||
self.exclude_globs.dedup();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CheckpointConfig> for CheckpointSettings {
|
||||
fn from(value: CheckpointConfig) -> Self {
|
||||
let mut exclude_globs = value.exclude_globs;
|
||||
exclude_globs.sort();
|
||||
exclude_globs.dedup();
|
||||
Self { exclude_globs }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct PullRequestConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub draft: Option<bool>,
|
||||
pub auto_merge: Option<bool>,
|
||||
pub merge_strategy: Option<MergeStrategy>,
|
||||
}
|
||||
|
||||
impl From<PullRequestConfig> for PullRequestSettings {
|
||||
fn from(value: PullRequestConfig) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or(false),
|
||||
draft: value.draft.unwrap_or(true),
|
||||
auto_merge: value.auto_merge.unwrap_or(false),
|
||||
merge_strategy: value.merge_strategy.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ArtifactsConfig {
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ArtifactsConfig> for ArtifactsSettings {
|
||||
fn from(value: ArtifactsConfig) -> Self {
|
||||
Self {
|
||||
include: value.include,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct GitHubConfig {
|
||||
#[serde(default)]
|
||||
pub permissions: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl From<GitHubConfig> for GitHubSettings {
|
||||
fn from(value: GitHubConfig) -> Self {
|
||||
Self {
|
||||
permissions: value.permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct LlmConfig {
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fallbacks: Option<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
impl From<LlmConfig> for LlmSettings {
|
||||
fn from(value: LlmConfig) -> Self {
|
||||
Self {
|
||||
model: value.model,
|
||||
provider: value.provider,
|
||||
fallbacks: value.fallbacks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct SetupConfig {
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<SetupConfig> for SetupSettings {
|
||||
fn from(value: SetupConfig) -> Self {
|
||||
Self {
|
||||
commands: value.commands,
|
||||
timeout_ms: value.timeout_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate a run config from a TOML file.
|
||||
///
|
||||
/// The `graph` path in the returned config is resolved relative to the
|
||||
/// TOML file's parent directory. Any `dockerfile = { path = "..." }` is
|
||||
/// resolved to inline content.
|
||||
///
|
||||
/// `${env.VARNAME}` references in `[sandbox.env]` are NOT resolved here —
|
||||
/// call [`resolve_sandbox_env`] separately after snapshotting, so that
|
||||
/// plaintext secrets are never written to disk.
|
||||
pub fn load_run_config(path: &Path) -> anyhow::Result<ConfigLayer> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let mut config = parse_run_config(&contents)?;
|
||||
|
||||
let config_dir = path.parent().unwrap_or(Path::new("."));
|
||||
resolve_dockerfile(&mut config, config_dir)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values.
|
||||
///
|
||||
/// Only whole-value references are supported (no partial interpolation).
|
||||
/// Missing host env vars produce a hard error.
|
||||
pub fn resolve_sandbox_env(config: &mut ConfigLayer) -> anyhow::Result<()> {
|
||||
if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) {
|
||||
resolve_env_refs(env)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `${env.VARNAME}` patterns in a map of env vars.
|
||||
///
|
||||
/// If the entire value is `${env.VARNAME}`, it is replaced with the host
|
||||
/// environment variable. Any other value is left as-is. Missing host
|
||||
/// variables produce an error.
|
||||
pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()> {
|
||||
for (key, value) in env.iter_mut() {
|
||||
if let Some(var_name) = value
|
||||
.strip_prefix("${env.")
|
||||
.and_then(|s| s.strip_suffix('}'))
|
||||
{
|
||||
*value = std::env::var(var_name).with_context(|| {
|
||||
format!("sandbox.env.{key}: host environment variable {var_name:?} is not set")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If the config contains a `dockerfile = { path = "..." }`, read the file
|
||||
/// and replace it with `DockerfileSource::Inline(contents)`.
|
||||
fn resolve_dockerfile(config: &mut ConfigLayer, config_dir: &Path) -> anyhow::Result<()> {
|
||||
let source = config
|
||||
.sandbox
|
||||
.as_mut()
|
||||
.and_then(|s| s.daytona.as_mut())
|
||||
.and_then(|d| d.snapshot.as_mut())
|
||||
.and_then(|snap| snap.dockerfile.as_mut());
|
||||
|
||||
if let Some(DockerfileSource::Path { path: ref rel }) = source {
|
||||
let path = config_dir.join(rel);
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read dockerfile at {}", path.display()))?;
|
||||
debug!(path = %path.display(), "Resolved dockerfile from path");
|
||||
*source.unwrap() = DockerfileSource::Inline(contents);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the graph path relative to the TOML file's parent directory.
|
||||
pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf {
|
||||
let graph_path = Path::new(graph);
|
||||
if graph_path.is_absolute() {
|
||||
graph_path.to_path_buf()
|
||||
} else {
|
||||
toml_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(graph_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and parse a run config from a TOML file.
|
||||
pub fn parse_run_config(contents: &str) -> anyhow::Result<ConfigLayer> {
|
||||
let mut config: ConfigLayer =
|
||||
toml::from_str(contents).context("Failed to parse run config TOML")?;
|
||||
|
||||
if config.graph.is_none() {
|
||||
config.graph = Some("workflow.fabro".to_string());
|
||||
}
|
||||
|
||||
let version = config.version.unwrap_or(0);
|
||||
if version != SUPPORTED_VERSION {
|
||||
bail!(
|
||||
"Unsupported run config version {version}. Only version {SUPPORTED_VERSION} is supported.",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
ConfigLayer::parse(contents).context("Failed to parse run config TOML")
|
||||
}
|
||||
|
||||
/// Load and parse a run config from a TOML file.
|
||||
///
|
||||
/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file`
|
||||
/// paths are anchored at the directory of `path` at load time.
|
||||
pub fn load_run_config(path: &Path) -> anyhow::Result<ConfigLayer> {
|
||||
ConfigLayer::load(path)
|
||||
.with_context(|| format!("Failed to parse workflow config at {}", path.display()))
|
||||
}
|
||||
|
||||
/// Resolve a graph path relative to a workflow.toml.
|
||||
#[must_use]
|
||||
pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf {
|
||||
workflow_toml
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(graph_relative)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_types::settings::run::RunGoalLayer;
|
||||
|
||||
#[test]
|
||||
fn load_run_config_rewrites_relative_goal_file_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let workflow_dir = tmp.path().join("fabro").join("workflows").join("demo");
|
||||
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||||
let workflow_toml = workflow_dir.join("workflow.toml");
|
||||
std::fs::write(
|
||||
&workflow_toml,
|
||||
r#"_version = 1
|
||||
|
||||
[run.goal]
|
||||
file = "prompts/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_run_config(&workflow_toml).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
let expected = workflow_dir.join("prompts").join("goal.md");
|
||||
assert_eq!(file.as_source(), expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_run_config_leaves_absolute_goal_file_untouched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let workflow_toml = tmp.path().join("workflow.toml");
|
||||
std::fs::write(
|
||||
&workflow_toml,
|
||||
r#"_version = 1
|
||||
|
||||
[run.goal]
|
||||
file = "/etc/fabro/goal.md"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_run_config(&workflow_toml).unwrap();
|
||||
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
|
||||
panic!("expected file variant");
|
||||
};
|
||||
assert_eq!(file.as_source(), "/etc/fabro/goal.md");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use fabro_types::settings::sandbox::{
|
||||
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
|
||||
LocalSandboxSettings, SandboxSettings, WorktreeMode,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct DaytonaConfig {
|
||||
pub auto_stop_interval: Option<i32>,
|
||||
pub labels: Option<HashMap<String, String>>,
|
||||
pub snapshot: Option<DaytonaSnapshotConfig>,
|
||||
pub network: Option<DaytonaNetwork>,
|
||||
/// Skip git repo detection and cloning during initialization.
|
||||
pub skip_clone: Option<bool>,
|
||||
}
|
||||
|
||||
impl TryFrom<DaytonaConfig> for DaytonaSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: DaytonaConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
auto_stop_interval: value.auto_stop_interval,
|
||||
labels: value.labels,
|
||||
snapshot: value.snapshot.map(TryInto::try_into).transpose()?,
|
||||
network: value.network,
|
||||
skip_clone: value.skip_clone.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot configuration: when present, the sandbox is created from a snapshot
|
||||
/// instead of a bare Docker image.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct DaytonaSnapshotConfig {
|
||||
pub name: Option<String>,
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
|
||||
impl TryFrom<DaytonaSnapshotConfig> for DaytonaSnapshotSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: DaytonaSnapshotConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
name: value
|
||||
.name
|
||||
.ok_or_else(|| anyhow!("sandbox.daytona.snapshot.name is required"))?,
|
||||
cpu: value.cpu,
|
||||
memory: value.memory,
|
||||
disk: value.disk,
|
||||
dockerfile: value.dockerfile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct LocalSandboxConfig {
|
||||
pub worktree_mode: Option<WorktreeMode>,
|
||||
}
|
||||
|
||||
impl From<LocalSandboxConfig> for LocalSandboxSettings {
|
||||
fn from(value: LocalSandboxConfig) -> Self {
|
||||
Self {
|
||||
worktree_mode: value.worktree_mode.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct SandboxConfig {
|
||||
pub provider: Option<String>,
|
||||
pub preserve: Option<bool>,
|
||||
pub devcontainer: Option<bool>,
|
||||
pub local: Option<LocalSandboxConfig>,
|
||||
pub daytona: Option<DaytonaConfig>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl TryFrom<SandboxConfig> for SandboxSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: SandboxConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
provider: value.provider,
|
||||
preserve: value.preserve,
|
||||
devcontainer: value.devcontainer,
|
||||
local: value.local.map(Into::into),
|
||||
daytona: value.daytona.map(TryInto::try_into).transpose()?,
|
||||
env: value.env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use fabro_types::Settings;
|
||||
pub use fabro_types::settings::server::{
|
||||
ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider,
|
||||
AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings,
|
||||
SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct AuthConfig {
|
||||
pub provider: Option<AuthProvider>,
|
||||
#[serde(default)]
|
||||
pub allowed_usernames: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<AuthConfig> for AuthSettings {
|
||||
fn from(value: AuthConfig) -> Self {
|
||||
Self {
|
||||
provider: value.provider.unwrap_or_default(),
|
||||
allowed_usernames: value.allowed_usernames,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct TlsConfig {
|
||||
pub cert: Option<PathBuf>,
|
||||
pub key: Option<PathBuf>,
|
||||
pub ca: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TryFrom<TlsConfig> for TlsSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: TlsConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
cert: value
|
||||
.cert
|
||||
.ok_or_else(|| anyhow!("tls.cert is required when tls is configured"))?,
|
||||
key: value
|
||||
.key
|
||||
.ok_or_else(|| anyhow!("tls.key is required when tls is configured"))?,
|
||||
ca: value
|
||||
.ca
|
||||
.ok_or_else(|| anyhow!("tls.ca is required when tls is configured"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ApiConfig {
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub authentication_strategies: Vec<ApiAuthStrategy>,
|
||||
pub tls: Option<TlsConfig>,
|
||||
}
|
||||
|
||||
fn default_base_url() -> String {
|
||||
"http://localhost:3000/api/v1".to_string()
|
||||
}
|
||||
|
||||
impl TryFrom<ApiConfig> for ApiSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ApiConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
base_url: value.base_url.unwrap_or_else(default_base_url),
|
||||
authentication_strategies: value.authentication_strategies,
|
||||
tls: value.tls.map(TryInto::try_into).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct GitAuthorConfig {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl From<GitAuthorConfig> for GitAuthorSettings {
|
||||
fn from(value: GitAuthorConfig) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct WebhookConfig {
|
||||
pub strategy: Option<WebhookStrategy>,
|
||||
}
|
||||
|
||||
impl TryFrom<WebhookConfig> for WebhookSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: WebhookConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
strategy: value
|
||||
.strategy
|
||||
.ok_or_else(|| anyhow!("git.webhooks.strategy is required"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct GitConfig {
|
||||
pub provider: Option<GitProvider>,
|
||||
pub app_id: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
pub author: Option<GitAuthorConfig>,
|
||||
pub webhooks: Option<WebhookConfig>,
|
||||
}
|
||||
|
||||
impl TryFrom<GitConfig> for GitSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: GitConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
provider: value.provider.unwrap_or_default(),
|
||||
app_id: value.app_id,
|
||||
client_id: value.client_id,
|
||||
slug: value.slug,
|
||||
author: value.author.map(Into::into).unwrap_or_default(),
|
||||
webhooks: value.webhooks.map(TryInto::try_into).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct WebConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub url: Option<String>,
|
||||
pub auth: Option<AuthConfig>,
|
||||
}
|
||||
|
||||
fn default_web_url() -> String {
|
||||
"http://localhost:3000".to_string()
|
||||
}
|
||||
|
||||
impl From<WebConfig> for WebSettings {
|
||||
fn from(value: WebConfig) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or(true),
|
||||
url: value.url.unwrap_or_else(default_web_url),
|
||||
auth: value.auth.map(Into::into).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct SlackConfig {
|
||||
pub default_channel: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SlackConfig> for SlackSettings {
|
||||
fn from(value: SlackConfig) -> Self {
|
||||
Self {
|
||||
default_channel: value.default_channel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct FeaturesConfig {
|
||||
pub session_sandboxes: Option<bool>,
|
||||
/// Experimental: enable automatic retro generation after workflow runs.
|
||||
pub retros: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<FeaturesConfig> for FeaturesSettings {
|
||||
fn from(value: FeaturesConfig) -> Self {
|
||||
Self {
|
||||
session_sandboxes: value.session_sandboxes.unwrap_or(false),
|
||||
retros: value.retros.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct LogConfig {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
impl From<LogConfig> for LogSettings {
|
||||
fn from(value: LogConfig) -> Self {
|
||||
Self { level: value.level }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the storage directory: config value > default `~/.fabro`.
|
||||
pub fn resolve_storage_dir(settings: &Settings) -> PathBuf {
|
||||
settings.storage_dir()
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
use fabro_types::Settings;
|
||||
|
||||
use crate::config::ConfigLayer;
|
||||
|
||||
impl TryFrom<ConfigLayer> for Settings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ConfigLayer) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
version: value.version,
|
||||
goal: value.goal,
|
||||
goal_file: value.goal_file,
|
||||
graph: value.graph,
|
||||
labels: value.labels,
|
||||
work_dir: value.work_dir,
|
||||
llm: value.llm.map(Into::into),
|
||||
setup: value.setup.map(Into::into),
|
||||
sandbox: value.sandbox.map(TryInto::try_into).transpose()?,
|
||||
vars: value.vars,
|
||||
checkpoint: value.checkpoint.into(),
|
||||
pull_request: value.pull_request.map(Into::into),
|
||||
artifacts: value.artifacts.map(Into::into),
|
||||
hooks: value.hooks,
|
||||
mcp_servers: value.mcp_servers,
|
||||
github: value.github.map(Into::into),
|
||||
server: value.server.map(TryInto::try_into).transpose()?,
|
||||
exec: value.exec.map(Into::into),
|
||||
prevent_idle_sleep: value.prevent_idle_sleep,
|
||||
verbose: value.verbose,
|
||||
upgrade_check: value.upgrade_check,
|
||||
dry_run: value.dry_run,
|
||||
auto_approve: value.auto_approve,
|
||||
no_retro: value.no_retro,
|
||||
storage_dir: value.storage_dir,
|
||||
max_concurrent_runs: value.max_concurrent_runs,
|
||||
artifact_storage: value.artifact_storage,
|
||||
web: value.web.map(Into::into),
|
||||
slack: value.slack.map(Into::into),
|
||||
api: value.api.map(TryInto::try_into).transpose()?,
|
||||
features: value.features.map(Into::into),
|
||||
log: value.log.map(Into::into),
|
||||
git: value.git.map(TryInto::try_into).transpose()?,
|
||||
fabro: value.fabro.map(Into::into),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&ConfigLayer> for Settings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: &ConfigLayer) -> Result<Self, Self::Error> {
|
||||
value.clone().try_into()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
//! User config loading.
|
||||
//!
|
||||
//! Exposes machine-level settings loading plus path helpers for the
|
||||
//! `~/.fabro/settings.toml` file. Runtime types that used to be
|
||||
//! re-exported from here live in `fabro_types::settings::user` now.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigLayer;
|
||||
use crate::home::Home;
|
||||
|
||||
pub use fabro_types::settings::user::{
|
||||
ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings,
|
||||
};
|
||||
|
||||
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
|
||||
pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml";
|
||||
pub const LEGACY_OLD_USER_CONFIG_FILENAME: &str = "user.toml";
|
||||
|
|
@ -20,67 +19,6 @@ pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG";
|
|||
|
||||
static WARNED_LEGACY_USER_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ClientTlsConfig {
|
||||
pub cert: Option<PathBuf>,
|
||||
pub key: Option<PathBuf>,
|
||||
pub ca: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TryFrom<ClientTlsConfig> for ClientTlsSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ClientTlsConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
cert: value.cert.ok_or_else(|| {
|
||||
anyhow!("server.tls.cert is required when server.tls is configured")
|
||||
})?,
|
||||
key: value.key.ok_or_else(|| {
|
||||
anyhow!("server.tls.key is required when server.tls is configured")
|
||||
})?,
|
||||
ca: value.ca.ok_or_else(|| {
|
||||
anyhow!("server.tls.ca is required when server.tls is configured")
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ServerConfig {
|
||||
pub target: Option<String>,
|
||||
pub tls: Option<ClientTlsConfig>,
|
||||
}
|
||||
|
||||
impl TryFrom<ServerConfig> for ServerSettings {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ServerConfig) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
target: value.target,
|
||||
tls: value.tls.map(TryInto::try_into).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
pub struct ExecConfig {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub permissions: Option<PermissionLevel>,
|
||||
pub output_format: Option<OutputFormat>,
|
||||
}
|
||||
|
||||
impl From<ExecConfig> for ExecSettings {
|
||||
fn from(value: ExecConfig) -> Self {
|
||||
Self {
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
permissions: value.permissions,
|
||||
output_format: value.output_format,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_settings_path() -> PathBuf {
|
||||
Home::from_env().user_config()
|
||||
}
|
||||
|
|
@ -126,15 +64,16 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool {
|
|||
.insert(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Load settings config from an explicit path or `~/.fabro/settings.toml`, returning defaults if the
|
||||
/// default file doesn't exist. An explicit path that doesn't exist is an error.
|
||||
/// Load settings config from an explicit path or `~/.fabro/settings.toml`,
|
||||
/// returning defaults if the default file doesn't exist. An explicit path that
|
||||
/// doesn't exist is an error.
|
||||
#[allow(clippy::print_stderr)]
|
||||
pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
|
||||
if let Some(explicit) = path
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from))
|
||||
{
|
||||
return crate::load_config_file(Some(&explicit), SETTINGS_CONFIG_FILENAME);
|
||||
return load_v2_layer_from_path(&explicit);
|
||||
}
|
||||
|
||||
for legacy_path in [
|
||||
|
|
@ -155,7 +94,16 @@ pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer>
|
|||
}
|
||||
}
|
||||
|
||||
crate::load_config_file(None, SETTINGS_CONFIG_FILENAME)
|
||||
let default = Home::from_env().root().join(SETTINGS_CONFIG_FILENAME);
|
||||
if default.is_file() {
|
||||
load_v2_layer_from_path(&default)
|
||||
} else {
|
||||
Ok(ConfigLayer::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_v2_layer_from_path(path: &Path) -> anyhow::Result<ConfigLayer> {
|
||||
ConfigLayer::load(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1 +1,350 @@
|
|||
pub use fabro_config::hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode};
|
||||
//! Hook configuration runtime types.
|
||||
//!
|
||||
//! These types are the runtime shape that the hook executor consumes. The
|
||||
//! v2 parse tree under `fabro_types::settings::run::HookEntry` is the
|
||||
//! *config-file* shape; this module lives in `fabro-hooks` because the
|
||||
//! behavior methods (`is_blocking`, `timeout`, `resolved_hook_type`,
|
||||
//! `runs_in_sandbox`, `effective_name`) are runtime concerns owned by the
|
||||
//! executor.
|
||||
//!
|
||||
//! [`bridge_hook`] converts a v2 `HookEntry` into the runtime
|
||||
//! [`HookDefinition`] and lives here (not in `fabro-types`) so the runtime
|
||||
//! shape stays owned by this crate.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
HookAgentMarker, HookEntry, HookEvent as V2HookEvent, HookTlsMode as V2HookTlsMode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lifecycle events that can trigger user-defined hooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
RunStart,
|
||||
RunComplete,
|
||||
RunFailed,
|
||||
StageStart,
|
||||
StageComplete,
|
||||
StageFailed,
|
||||
StageRetrying,
|
||||
EdgeSelected,
|
||||
ParallelStart,
|
||||
ParallelComplete,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxReady,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxCleanup,
|
||||
CheckpointSaved,
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
PostToolUseFailure,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
/// Whether hooks for this event block execution by default.
|
||||
#[must_use]
|
||||
pub fn is_blocking_by_default(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::RunStart
|
||||
| Self::StageStart
|
||||
| Self::EdgeSelected
|
||||
| Self::PreToolUse
|
||||
| Self::SandboxReady
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::RunStart => "run_start",
|
||||
Self::RunComplete => "run_complete",
|
||||
Self::RunFailed => "run_failed",
|
||||
Self::StageStart => "stage_start",
|
||||
Self::StageComplete => "stage_complete",
|
||||
Self::StageFailed => "stage_failed",
|
||||
Self::StageRetrying => "stage_retrying",
|
||||
Self::EdgeSelected => "edge_selected",
|
||||
Self::ParallelStart => "parallel_start",
|
||||
Self::ParallelComplete => "parallel_complete",
|
||||
Self::SandboxReady => "sandbox_ready",
|
||||
Self::SandboxCleanup => "sandbox_cleanup",
|
||||
Self::CheckpointSaved => "checkpoint_saved",
|
||||
Self::PreToolUse => "pre_tool_use",
|
||||
Self::PostToolUse => "post_tool_use",
|
||||
Self::PostToolUseFailure => "post_tool_use_failure",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS verification mode for HTTP hooks.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
/// Require `https://` and verify certificates (default).
|
||||
#[default]
|
||||
Verify,
|
||||
/// Require `https://` but skip certificate verification.
|
||||
NoVerify,
|
||||
/// Allow `http://`; skip certificate verification for `https://`.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How a hook is executed.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookType {
|
||||
Command {
|
||||
command: String,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
headers: Option<std::collections::HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
allowed_env_vars: Vec<String>,
|
||||
#[serde(default)]
|
||||
tls: TlsMode,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
Agent {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
max_tool_rounds: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single hook definition.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookDefinition {
|
||||
pub name: Option<String>,
|
||||
pub event: HookEvent,
|
||||
/// Inline command shorthand — if set, implies `type = "command"`.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
/// Explicit hook type (command or http). If omitted and `command` is set,
|
||||
/// defaults to `Command`.
|
||||
#[serde(flatten)]
|
||||
pub hook_type: Option<HookType>,
|
||||
/// Regex matched against node_id, handler_type, or event-specific fields.
|
||||
pub matcher: Option<String>,
|
||||
/// Override the event's default blocking behavior.
|
||||
pub blocking: Option<bool>,
|
||||
/// Timeout in milliseconds (default: 60_000).
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Run inside the sandbox (true, default) or on the host (false).
|
||||
pub sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
impl HookDefinition {
|
||||
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
|
||||
/// shorthand, then error.
|
||||
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
|
||||
if let Some(ref ht) = self.hook_type {
|
||||
return Some(Cow::Borrowed(ht));
|
||||
}
|
||||
self.command.as_ref().map(|cmd| {
|
||||
Cow::Owned(HookType::Command {
|
||||
command: cmd.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this hook is blocking for its event.
|
||||
#[must_use]
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
self.blocking
|
||||
.unwrap_or_else(|| self.event.is_blocking_by_default())
|
||||
}
|
||||
|
||||
/// Timeout duration for this hook.
|
||||
///
|
||||
/// Defaults: 30s for prompt hooks, 60s for all others.
|
||||
#[must_use]
|
||||
pub fn timeout(&self) -> std::time::Duration {
|
||||
if let Some(ms) = self.timeout_ms {
|
||||
return std::time::Duration::from_millis(ms);
|
||||
}
|
||||
let default_ms = match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Prompt { .. }) => 30_000,
|
||||
_ => 60_000,
|
||||
};
|
||||
std::time::Duration::from_millis(default_ms)
|
||||
}
|
||||
|
||||
/// Whether this hook runs in the sandbox.
|
||||
#[must_use]
|
||||
pub fn runs_in_sandbox(&self) -> bool {
|
||||
self.sandbox.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The effective name: explicit name or a generated one.
|
||||
#[must_use]
|
||||
pub fn effective_name(&self) -> String {
|
||||
if let Some(ref n) = self.name {
|
||||
return n.clone();
|
||||
}
|
||||
let event_str = self.event.to_string();
|
||||
match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Command { ref command }) => {
|
||||
let short = &command[..command.floor_char_boundary(20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
|
||||
Some(HookType::Prompt { ref prompt, .. } | HookType::Agent { ref prompt, .. }) => {
|
||||
let short = &prompt[..prompt.floor_char_boundary(20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
None => event_str,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level hook configuration: a list of hook definitions.
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookSettings {
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
}
|
||||
|
||||
impl HookSettings {
|
||||
/// Merge with another config. Concatenates lists; on name collisions, `other` wins.
|
||||
#[must_use]
|
||||
pub fn merge(self, other: Self) -> Self {
|
||||
let mut by_name: std::collections::HashMap<String, HookDefinition> =
|
||||
std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
|
||||
for hook in self.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
for hook in other.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
|
||||
let hooks = order
|
||||
.into_iter()
|
||||
.filter_map(|name| by_name.remove(&name))
|
||||
.collect();
|
||||
|
||||
Self { hooks }
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a v2 [`HookEntry`] into the runtime [`HookDefinition`] shape
|
||||
/// this crate's executor consumes.
|
||||
#[must_use]
|
||||
pub fn bridge_hook(hook: &HookEntry) -> HookDefinition {
|
||||
let hook_type = resolve_hook_type(hook);
|
||||
// If the hook is a script/command form, emit via the shorthand so
|
||||
// HookDefinition.command holds the full command and
|
||||
// HookDefinition.hook_type stays None. This avoids the duplicate
|
||||
// `command` key that would otherwise appear under `#[serde(flatten)]`.
|
||||
let command = if let Some(script) = &hook.script {
|
||||
Some(interp_to_string(script))
|
||||
} else {
|
||||
hook.command.as_ref().map(|command| {
|
||||
command
|
||||
.iter()
|
||||
.map(interp_to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
};
|
||||
HookDefinition {
|
||||
name: hook.name.clone().or_else(|| hook.id.clone()),
|
||||
event: bridge_hook_event(hook.event),
|
||||
command,
|
||||
hook_type,
|
||||
matcher: hook.matcher.clone(),
|
||||
blocking: hook.blocking,
|
||||
timeout_ms: hook
|
||||
.timeout
|
||||
.map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)),
|
||||
sandbox: hook.sandbox,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> {
|
||||
if hook.script.is_some() || hook.command.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(url) = &hook.url {
|
||||
let headers = if hook.headers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
hook.headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), interp_to_string(v)))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let tls = match hook.tls {
|
||||
Some(V2HookTlsMode::Verify) => TlsMode::Verify,
|
||||
Some(V2HookTlsMode::NoVerify) => TlsMode::NoVerify,
|
||||
Some(V2HookTlsMode::Off) => TlsMode::Off,
|
||||
None => TlsMode::default(),
|
||||
};
|
||||
return Some(HookType::Http {
|
||||
url: interp_to_string(url),
|
||||
headers,
|
||||
allowed_env_vars: hook.allowed_env_vars.clone(),
|
||||
tls,
|
||||
});
|
||||
}
|
||||
if matches!(hook.agent, Some(HookAgentMarker::Enabled)) {
|
||||
return Some(HookType::Agent {
|
||||
prompt: hook
|
||||
.prompt
|
||||
.as_ref()
|
||||
.map(interp_to_string)
|
||||
.unwrap_or_default(),
|
||||
model: hook.model.as_ref().map(interp_to_string),
|
||||
max_tool_rounds: hook.max_tool_rounds,
|
||||
});
|
||||
}
|
||||
hook.prompt.as_ref().map(|prompt| HookType::Prompt {
|
||||
prompt: interp_to_string(prompt),
|
||||
model: hook.model.as_ref().map(interp_to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn bridge_hook_event(event: V2HookEvent) -> HookEvent {
|
||||
match event {
|
||||
V2HookEvent::RunStart => HookEvent::RunStart,
|
||||
V2HookEvent::RunComplete => HookEvent::RunComplete,
|
||||
V2HookEvent::RunFailed => HookEvent::RunFailed,
|
||||
V2HookEvent::StageStart => HookEvent::StageStart,
|
||||
V2HookEvent::StageComplete => HookEvent::StageComplete,
|
||||
V2HookEvent::StageFailed => HookEvent::StageFailed,
|
||||
V2HookEvent::StageRetrying => HookEvent::StageRetrying,
|
||||
V2HookEvent::EdgeSelected => HookEvent::EdgeSelected,
|
||||
V2HookEvent::ParallelStart => HookEvent::ParallelStart,
|
||||
V2HookEvent::ParallelComplete => HookEvent::ParallelComplete,
|
||||
V2HookEvent::SandboxReady => HookEvent::SandboxReady,
|
||||
V2HookEvent::SandboxCleanup => HookEvent::SandboxCleanup,
|
||||
V2HookEvent::CheckpointSaved => HookEvent::CheckpointSaved,
|
||||
V2HookEvent::PreToolUse => HookEvent::PreToolUse,
|
||||
V2HookEvent::PostToolUse => HookEvent::PostToolUse,
|
||||
V2HookEvent::PostToolUseFailure => HookEvent::PostToolUseFailure,
|
||||
}
|
||||
}
|
||||
|
||||
fn interp_to_string(value: &InterpString) -> String {
|
||||
value.as_source()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
pub use fabro_config::hook::HookEvent;
|
||||
pub use crate::config::HookEvent;
|
||||
|
||||
use fabro_types::RunId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ use proc_macro::TokenStream;
|
|||
use quote::quote;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::{
|
||||
Data, DeriveInput, Fields, Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input,
|
||||
};
|
||||
use syn::{Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input};
|
||||
|
||||
enum E2eRequirement {
|
||||
Twin,
|
||||
|
|
@ -33,55 +31,6 @@ impl Parse for E2eRequirement {
|
|||
}
|
||||
}
|
||||
|
||||
#[proc_macro_derive(Combine)]
|
||||
pub fn derive_combine(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let ident = input.ident;
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
let body = match input.data {
|
||||
Data::Struct(data) => match data.fields {
|
||||
Fields::Named(fields) => {
|
||||
let combined = fields.named.into_iter().map(|field| {
|
||||
let ident = field.ident.expect("named field");
|
||||
quote! {
|
||||
#ident: ::fabro_types::combine::Combine::combine(self.#ident, other.#ident)
|
||||
}
|
||||
});
|
||||
quote! {
|
||||
Self {
|
||||
#(#combined,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
Fields::Unnamed(fields) => {
|
||||
let combined = fields.unnamed.iter().enumerate().map(|(index, _)| {
|
||||
let index = syn::Index::from(index);
|
||||
quote! {
|
||||
::fabro_types::combine::Combine::combine(self.#index, other.#index)
|
||||
}
|
||||
});
|
||||
quote! {
|
||||
Self(#(#combined),*)
|
||||
}
|
||||
}
|
||||
Fields::Unit => quote!(Self),
|
||||
},
|
||||
Data::Enum(_) | Data::Union(_) => {
|
||||
quote!(self)
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
impl #impl_generics ::fabro_types::combine::Combine for #ident #ty_generics #where_clause {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
#body
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn e2e_test(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let requirements =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,281 @@
|
|||
pub use fabro_config::mcp::{
|
||||
McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs,
|
||||
default_tool_timeout_secs,
|
||||
};
|
||||
//! MCP server configuration runtime types.
|
||||
//!
|
||||
//! The v2 parse tree lives in `fabro_types::settings::run::McpEntryLayer`.
|
||||
//! This module owns the runtime shape (flattened, with timeout helpers) that
|
||||
//! the MCP client consumes at execution time. Conversion from the v2 shape
|
||||
//! lives in [`bridge_mcp_entry`] / [`bridge_mcps`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::McpEntryLayer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[must_use]
|
||||
pub fn default_startup_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_tool_timeout_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerSettings {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_startup_timeout_secs")]
|
||||
pub startup_timeout_secs: u64,
|
||||
#[serde(default = "default_tool_timeout_secs")]
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl McpServerSettings {
|
||||
#[must_use]
|
||||
pub fn startup_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.startup_timeout_secs)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.tool_timeout_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
/// MCP server that runs inside a sandbox and is accessed via HTTP preview URL.
|
||||
/// During session init, the server is started inside the sandbox and this
|
||||
/// variant is resolved into an `Http` transport using the sandbox's preview URL.
|
||||
Sandbox {
|
||||
command: Vec<String>,
|
||||
port: u16,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// MCP server entry as it appears in TOML config files (without a `name` field).
|
||||
///
|
||||
/// Converted to [`McpServerSettings`] via [`McpServerEntry::into_config`].
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct McpServerEntry {
|
||||
#[serde(flatten)]
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_startup_timeout_secs")]
|
||||
pub startup_timeout_secs: u64,
|
||||
#[serde(default = "default_tool_timeout_secs")]
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl McpServerEntry {
|
||||
#[must_use]
|
||||
pub fn into_config(self, name: String) -> McpServerSettings {
|
||||
McpServerSettings {
|
||||
name,
|
||||
transport: self.transport,
|
||||
startup_timeout_secs: self.startup_timeout_secs,
|
||||
tool_timeout_secs: self.tool_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a map of v2 `McpEntryLayer` entries into runtime `McpServerEntry`s.
|
||||
#[must_use]
|
||||
pub fn bridge_mcps(mcps: &HashMap<String, McpEntryLayer>) -> HashMap<String, McpServerEntry> {
|
||||
mcps.iter()
|
||||
.map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a single v2 `McpEntryLayer` into the runtime `McpServerEntry`.
|
||||
#[must_use]
|
||||
pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry {
|
||||
let transport = match entry {
|
||||
McpEntryLayer::Stdio {
|
||||
script,
|
||||
command,
|
||||
env,
|
||||
..
|
||||
} => {
|
||||
let command_vec: Vec<String> = if let Some(script) = script {
|
||||
vec!["sh".into(), "-c".into(), interp_to_string(script)]
|
||||
} else if let Some(command) = command {
|
||||
command.iter().map(interp_to_string).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
McpTransport::Stdio {
|
||||
command: command_vec,
|
||||
env: env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), interp_to_string(v)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
McpEntryLayer::Http { url, headers, .. } => McpTransport::Http {
|
||||
url: interp_to_string(url),
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), interp_to_string(v)))
|
||||
.collect(),
|
||||
},
|
||||
McpEntryLayer::Sandbox {
|
||||
script,
|
||||
command,
|
||||
port,
|
||||
env,
|
||||
..
|
||||
} => {
|
||||
let command_vec: Vec<String> = if let Some(script) = script {
|
||||
vec!["sh".into(), "-c".into(), interp_to_string(script)]
|
||||
} else if let Some(command) = command {
|
||||
command.iter().map(interp_to_string).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
McpTransport::Sandbox {
|
||||
command: command_vec,
|
||||
port: *port,
|
||||
env: env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), interp_to_string(v)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (startup_secs, tool_secs) = match entry {
|
||||
McpEntryLayer::Http {
|
||||
startup_timeout,
|
||||
tool_timeout,
|
||||
..
|
||||
}
|
||||
| McpEntryLayer::Stdio {
|
||||
startup_timeout,
|
||||
tool_timeout,
|
||||
..
|
||||
}
|
||||
| McpEntryLayer::Sandbox {
|
||||
startup_timeout,
|
||||
tool_timeout,
|
||||
..
|
||||
} => (
|
||||
startup_timeout.map_or(default_startup_timeout_secs(), |d| d.as_std().as_secs()),
|
||||
tool_timeout.map_or(default_tool_timeout_secs(), |d| d.as_std().as_secs()),
|
||||
),
|
||||
};
|
||||
|
||||
McpServerEntry {
|
||||
transport,
|
||||
startup_timeout_secs: startup_secs,
|
||||
tool_timeout_secs: tool_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn interp_to_string(value: &InterpString) -> String {
|
||||
value.as_source()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn stdio_config_construction() {
|
||||
let config = McpServerSettings {
|
||||
name: "test-server".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec![
|
||||
"npx".into(),
|
||||
"-y".into(),
|
||||
"@modelcontextprotocol/server-filesystem".into(),
|
||||
],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "test-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(10));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_construction() {
|
||||
let config = McpServerSettings {
|
||||
name: "remote-server".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
|
||||
},
|
||||
startup_timeout_secs: 30,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "remote-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_stdio() {
|
||||
let config = McpServerSettings {
|
||||
name: "fs".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["node".into(), "server.js".into()],
|
||||
env: HashMap::from([("NODE_ENV".into(), "production".into())]),
|
||||
},
|
||||
startup_timeout_secs: 15,
|
||||
tool_timeout_secs: 90,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "fs");
|
||||
assert_eq!(deserialized.startup_timeout_secs, 15);
|
||||
assert_eq!(deserialized.tool_timeout_secs, 90);
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_http() {
|
||||
let config = McpServerSettings {
|
||||
name: "remote".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://mcp.example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "remote");
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_applied() {
|
||||
let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#;
|
||||
let config: McpServerSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.startup_timeout_secs, 10);
|
||||
assert_eq!(config.tool_timeout_secs, 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
//! Sandbox configuration runtime types.
|
||||
//!
|
||||
//! These types are the runtime shape that the sandbox providers consume.
|
||||
//! The v2 parse tree lives in `fabro_types::settings::run::RunSandboxLayer`.
|
||||
//! Conversion from the v2 shape lives in [`bridge_sandbox`].
|
||||
//!
|
||||
//! The `DaytonaSettings`/`DaytonaSnapshotSettings` names are kept for
|
||||
//! backward compatibility with the old import path; [`crate::daytona`]
|
||||
//! continues to re-export them under `DaytonaConfig`/`DaytonaSnapshotConfig`
|
||||
//! aliases.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
DaytonaDockerfileLayer, DaytonaNetworkLayer, RunSandboxLayer, WorktreeMode as V2WorktreeMode,
|
||||
};
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -13,7 +28,7 @@ pub struct DaytonaSettings {
|
|||
pub skip_clone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, crate::Combine)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DaytonaNetwork {
|
||||
Block,
|
||||
AllowAll,
|
||||
|
|
@ -98,7 +113,7 @@ impl<'de> Deserialize<'de> for DaytonaNetwork {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, crate::Combine)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum DockerfileSource {
|
||||
Inline(String),
|
||||
|
|
@ -114,7 +129,7 @@ pub struct DaytonaSnapshotSettings {
|
|||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorktreeMode {
|
||||
Always,
|
||||
|
|
@ -139,3 +154,81 @@ pub struct SandboxSettings {
|
|||
pub daytona: Option<DaytonaSettings>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Convert a v2 [`RunSandboxLayer`] into the runtime [`SandboxSettings`] shape.
|
||||
#[must_use]
|
||||
pub fn bridge_sandbox(sb: &RunSandboxLayer) -> SandboxSettings {
|
||||
SandboxSettings {
|
||||
provider: sb.provider.clone(),
|
||||
preserve: sb.preserve,
|
||||
devcontainer: sb.devcontainer,
|
||||
local: sb.local.as_ref().map(|local| LocalSandboxSettings {
|
||||
worktree_mode: local
|
||||
.worktree_mode
|
||||
.map(bridge_worktree_mode)
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
daytona: sb.daytona.as_ref().map(|d| DaytonaSettings {
|
||||
auto_stop_interval: d.auto_stop_interval,
|
||||
labels: if d.labels.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(d.labels.clone())
|
||||
},
|
||||
snapshot: d.snapshot.as_ref().and_then(|s| {
|
||||
s.name.as_ref().map(|name| DaytonaSnapshotSettings {
|
||||
name: name.clone(),
|
||||
cpu: s.cpu,
|
||||
memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())),
|
||||
disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())),
|
||||
dockerfile: s.dockerfile.as_ref().map(|d| match d {
|
||||
DaytonaDockerfileLayer::Inline(text) => {
|
||||
DockerfileSource::Inline(text.clone())
|
||||
}
|
||||
DaytonaDockerfileLayer::Path { path } => {
|
||||
DockerfileSource::Path { path: path.clone() }
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
network: d.network.as_ref().map(|n| match n {
|
||||
DaytonaNetworkLayer::Block => DaytonaNetwork::Block,
|
||||
DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll,
|
||||
DaytonaNetworkLayer::AllowList { allow_list } => {
|
||||
DaytonaNetwork::AllowList(allow_list.clone())
|
||||
}
|
||||
}),
|
||||
skip_clone: d.skip_clone.unwrap_or(false),
|
||||
}),
|
||||
env: if sb.env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
sb.env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), interp_to_string(v)))
|
||||
.collect(),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a v2 [`V2WorktreeMode`] into the runtime [`WorktreeMode`].
|
||||
#[must_use]
|
||||
pub fn bridge_worktree_mode(m: V2WorktreeMode) -> WorktreeMode {
|
||||
match m {
|
||||
V2WorktreeMode::Always => WorktreeMode::Always,
|
||||
V2WorktreeMode::Clean => WorktreeMode::Clean,
|
||||
V2WorktreeMode::Dirty => WorktreeMode::Dirty,
|
||||
V2WorktreeMode::Never => WorktreeMode::Never,
|
||||
}
|
||||
}
|
||||
|
||||
fn interp_to_string(value: &InterpString) -> String {
|
||||
value.as_source()
|
||||
}
|
||||
|
||||
fn size_to_gb_i32(bytes: u64) -> i32 {
|
||||
let gb = bytes / 1_000_000_000;
|
||||
i32::try_from(gb).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken;
|
|||
const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
|
||||
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
|
||||
|
||||
pub use fabro_config::sandbox::{
|
||||
pub use crate::config::{
|
||||
DaytonaNetwork, DaytonaSettings as DaytonaConfig,
|
||||
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod config;
|
||||
pub mod sandbox;
|
||||
pub mod sandbox_spec;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::{RunId, settings::WorktreeMode};
|
||||
use crate::config::WorktreeMode;
|
||||
use fabro_types::RunId;
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use anyhow::anyhow;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ serde_yaml = "0.9"
|
|||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
tracing.workspace = true
|
||||
ulid.workspace = true
|
||||
uuid.workspace = true
|
||||
|
|
|
|||
|
|
@ -1319,59 +1319,39 @@ mod runs {
|
|||
}
|
||||
|
||||
pub(super) fn settings() -> serde_json::Value {
|
||||
serde_json::to_value(fabro_types::Settings {
|
||||
version: Some(1),
|
||||
goal: Some("Add rate limiting to auth endpoints".into()),
|
||||
graph: Some("implement.fabro".into()),
|
||||
work_dir: Some("/workspace/api-server".into()),
|
||||
llm: Some(fabro_config::run::LlmSettings {
|
||||
model: Some("claude-opus-4-6".into()),
|
||||
provider: Some("anthropic".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: Some(fabro_config::run::SetupSettings {
|
||||
commands: vec!["bun install".into(), "bun run typecheck".into()],
|
||||
timeout_ms: Some(120_000),
|
||||
}),
|
||||
sandbox: Some(fabro_config::sandbox::SandboxSettings {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
local: None,
|
||||
daytona: Some(fabro_sandbox::daytona::DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
labels: Some(std::collections::HashMap::from([(
|
||||
"project".into(),
|
||||
"api-server".into(),
|
||||
)])),
|
||||
snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig {
|
||||
name: "api-server-dev".into(),
|
||||
cpu: Some(4),
|
||||
memory: Some(8),
|
||||
disk: Some(10),
|
||||
dockerfile: None,
|
||||
}),
|
||||
network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block),
|
||||
skip_clone: false,
|
||||
}),
|
||||
env: None,
|
||||
}),
|
||||
vars: Some(std::collections::HashMap::from([
|
||||
(
|
||||
"repo_url".into(),
|
||||
"https://github.com/org/api-server".into(),
|
||||
),
|
||||
("branch".into(), "feature/rate-limiting".into()),
|
||||
])),
|
||||
hooks: vec![],
|
||||
checkpoint: Default::default(),
|
||||
pull_request: None,
|
||||
artifacts: None,
|
||||
mcp_servers: Default::default(),
|
||||
github: None,
|
||||
..Default::default()
|
||||
// v2 SettingsFile shape — matches what /api/v1/runs/:id/settings
|
||||
// returns in production, so the demo renders identically.
|
||||
serde_json::json!({
|
||||
"_version": 1,
|
||||
"run": {
|
||||
"goal": "Add rate limiting to auth endpoints",
|
||||
"working_dir": "/workspace/api-server",
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"name": "claude-opus-4-6"
|
||||
},
|
||||
"prepare": {
|
||||
"steps": [
|
||||
{ "command": ["bun", "install"] },
|
||||
{ "command": ["bun", "run", "typecheck"] }
|
||||
],
|
||||
"timeout": "120s"
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": "daytona",
|
||||
"daytona": {
|
||||
"auto_stop_interval": 60,
|
||||
"labels": { "project": "api-server" },
|
||||
"snapshot": {
|
||||
"name": "api-server-dev",
|
||||
"cpu": 4,
|
||||
"memory": "8GB",
|
||||
"disk": "10GB"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1483,68 +1463,64 @@ mod insights {
|
|||
}
|
||||
|
||||
mod settings {
|
||||
use fabro_config::server::*;
|
||||
use fabro_types::Settings;
|
||||
|
||||
pub(super) fn server_settings() -> serde_json::Value {
|
||||
serde_json::to_value(Settings {
|
||||
storage_dir: Some("/home/fabro/.fabro".into()),
|
||||
max_concurrent_runs: Some(10),
|
||||
web: Some(WebSettings {
|
||||
enabled: true,
|
||||
url: "https://fabro.example.com".into(),
|
||||
auth: AuthSettings {
|
||||
provider: AuthProvider::Github,
|
||||
allowed_usernames: vec!["brynary".into(), "alice".into()],
|
||||
// v2 SettingsFile shape — matches what /api/v1/settings returns in
|
||||
// production, so the demo renders identically.
|
||||
serde_json::json!({
|
||||
"_version": 1,
|
||||
"server": {
|
||||
"storage": {
|
||||
"root": "/home/fabro/.fabro"
|
||||
},
|
||||
}),
|
||||
api: Some(ApiSettings {
|
||||
base_url: "https://api.fabro.example.com".into(),
|
||||
authentication_strategies: vec![ApiAuthStrategy::Jwt],
|
||||
tls: None,
|
||||
}),
|
||||
git: Some(GitSettings {
|
||||
provider: GitProvider::Github,
|
||||
app_id: Some("12345".into()),
|
||||
client_id: Some("Iv1.abc123".into()),
|
||||
slug: Some("fabro-dev".into()),
|
||||
author: Default::default(),
|
||||
webhooks: None,
|
||||
}),
|
||||
features: Some(FeaturesSettings {
|
||||
session_sandboxes: false,
|
||||
retros: false,
|
||||
}),
|
||||
log: Default::default(),
|
||||
llm: Some(fabro_config::run::LlmSettings {
|
||||
model: Some("claude-sonnet".into()),
|
||||
provider: Some("anthropic".into()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
setup: None,
|
||||
sandbox: Some(fabro_config::sandbox::SandboxSettings {
|
||||
provider: Some("daytona".into()),
|
||||
preserve: None,
|
||||
devcontainer: None,
|
||||
local: None,
|
||||
daytona: Some(fabro_sandbox::daytona::DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
labels: None,
|
||||
snapshot: None,
|
||||
network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block),
|
||||
skip_clone: false,
|
||||
}),
|
||||
env: None,
|
||||
}),
|
||||
vars: None,
|
||||
checkpoint: Default::default(),
|
||||
pull_request: None,
|
||||
artifacts: None,
|
||||
hooks: vec![],
|
||||
mcp_servers: Default::default(),
|
||||
github: None,
|
||||
..Default::default()
|
||||
"scheduler": {
|
||||
"max_concurrent_runs": 10
|
||||
},
|
||||
"api": {
|
||||
"url": "https://api.fabro.example.com"
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"url": "https://fabro.example.com"
|
||||
},
|
||||
"auth": {
|
||||
"api": {
|
||||
"jwt": { "enabled": true }
|
||||
},
|
||||
"web": {
|
||||
"allowed_usernames": ["brynary", "alice"],
|
||||
"providers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"client_id": "Iv1.abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"github": {
|
||||
"app_id": "12345",
|
||||
"client_id": "Iv1.abc123",
|
||||
"slug": "fabro-dev"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"name": "claude-sonnet"
|
||||
},
|
||||
"sandbox": {
|
||||
"provider": "daytona",
|
||||
"daytona": {
|
||||
"auto_stop_interval": 60,
|
||||
"network": "block"
|
||||
}
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"session_sandboxes": false,
|
||||
"retros": false
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_config::server::ApiAuthStrategy;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::types::{Message, Request};
|
||||
use fabro_model::{Catalog, Provider};
|
||||
|
|
@ -294,10 +293,10 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
|||
.read()
|
||||
.expect("settings lock poisoned")
|
||||
.clone();
|
||||
let app_id = settings.app_id().map(str::to_owned);
|
||||
let slug = settings.slug().map(str::to_owned);
|
||||
let app_id = settings.github_app_id_str();
|
||||
let slug = settings.github_slug_str();
|
||||
let private_key_raw = state.secret_or_env("GITHUB_APP_PRIVATE_KEY");
|
||||
let client_id = settings.client_id().is_some();
|
||||
let client_id = settings.github_client_id_str().is_some();
|
||||
let client_secret = state.secret_or_env("GITHUB_APP_CLIENT_SECRET").is_some();
|
||||
let webhook_secret = state.secret_or_env("GITHUB_APP_WEBHOOK_SECRET").is_some();
|
||||
|
||||
|
|
@ -466,18 +465,24 @@ async fn check_brave_search(state: &AppState) -> CheckResult {
|
|||
}
|
||||
|
||||
fn check_crypto(state: &AppState) -> CheckResult {
|
||||
let settings = state
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
|
||||
let settings_file = state
|
||||
.settings
|
||||
.read()
|
||||
.expect("settings lock poisoned")
|
||||
.clone();
|
||||
let api = settings.api.clone().unwrap_or_default();
|
||||
let has_jwt = api
|
||||
.authentication_strategies
|
||||
.contains(&ApiAuthStrategy::Jwt);
|
||||
let has_mtls = api
|
||||
.authentication_strategies
|
||||
.contains(&ApiAuthStrategy::Mtls);
|
||||
let auth_api = settings_file
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.auth.as_ref())
|
||||
.and_then(|a| a.api.as_ref());
|
||||
let has_jwt = auth_api
|
||||
.and_then(|api| api.jwt.as_ref())
|
||||
.is_some_and(|jwt| jwt.enabled.unwrap_or(true));
|
||||
let has_mtls = auth_api
|
||||
.and_then(|api| api.mtls.as_ref())
|
||||
.is_some_and(|mtls| mtls.enabled.unwrap_or(true));
|
||||
|
||||
if !has_jwt && !has_mtls {
|
||||
return CheckResult {
|
||||
|
|
@ -485,7 +490,10 @@ fn check_crypto(state: &AppState) -> CheckResult {
|
|||
status: CheckStatus::Warning,
|
||||
summary: "no authentication configured".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: Some("Configure authentication_strategies in [api]".to_string()),
|
||||
remediation: Some(
|
||||
"Configure strategies under [server.auth.api.jwt] or [server.auth.api.mtls]"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -493,13 +501,32 @@ fn check_crypto(state: &AppState) -> CheckResult {
|
|||
let mut errors = Vec::new();
|
||||
|
||||
if has_mtls {
|
||||
if let Some(tls) = api.tls {
|
||||
let read = |path: &Path| -> Result<String, String> {
|
||||
let expanded = fabro_config::expand_tilde(path);
|
||||
use fabro_types::settings::server::ServerListenLayer;
|
||||
let listen_tls = settings_file
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.listen.as_ref())
|
||||
.and_then(|listen| match listen {
|
||||
ServerListenLayer::Tcp { tls, .. } => tls.as_ref(),
|
||||
ServerListenLayer::Unix { .. } => None,
|
||||
});
|
||||
if let Some(listen_tls) = listen_tls {
|
||||
let read = |raw: Option<String>, label: &str| -> Result<String, String> {
|
||||
let Some(path_str) = raw else {
|
||||
return Err(format!("server.listen.tls.{label} is not configured"));
|
||||
};
|
||||
let path = PathBuf::from(&path_str);
|
||||
let expanded = fabro_config::expand_tilde(&path);
|
||||
std::fs::read_to_string(&expanded)
|
||||
.map_err(|e| format!("{}: {e}", expanded.display()))
|
||||
};
|
||||
match (read(&tls.cert), read(&tls.key), read(&tls.ca)) {
|
||||
let cert = read(
|
||||
listen_tls.cert.as_ref().map(InterpString::as_source),
|
||||
"cert",
|
||||
);
|
||||
let key = read(listen_tls.key.as_ref().map(InterpString::as_source), "key");
|
||||
let ca = read(listen_tls.ca.as_ref().map(InterpString::as_source), "ca");
|
||||
match (cert, key, ca) {
|
||||
(Ok(cert_pem), Ok(key_pem), Ok(ca_pem)) => {
|
||||
if let Err(err) = validate_tls_cert(&cert_pem, chrono::Utc::now().timestamp()) {
|
||||
errors.push(err);
|
||||
|
|
@ -514,7 +541,7 @@ fn check_crypto(state: &AppState) -> CheckResult {
|
|||
_ => errors.push("failed to read mTLS files".to_string()),
|
||||
}
|
||||
} else {
|
||||
errors.push("mTLS configured but [api.tls] is missing".to_string());
|
||||
errors.push("mTLS configured but [server.listen.tls] is missing".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
|
|
@ -9,9 +10,19 @@ use serde::Deserialize;
|
|||
use tracing::warn;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::tls_config::TlsSettings;
|
||||
use crate::web_auth::SessionCookie;
|
||||
use fabro_config::server::ApiSettings;
|
||||
use fabro_types::RunAuthMethod;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
|
||||
/// Env var that explicitly opts the server into unauthenticated startup.
|
||||
///
|
||||
/// When set to `"1"`, [`resolve_auth_mode_with_lookup`] returns
|
||||
/// [`AuthMode::Disabled`] regardless of what `server.auth` says. This is the
|
||||
/// only escape hatch for running the server without configured
|
||||
/// authentication; it is off by default, so accidental misconfigurations
|
||||
/// fail closed.
|
||||
pub const FABRO_LOCAL_NO_AUTH_ENV: &str = "FABRO_LOCAL_NO_AUTH";
|
||||
|
||||
/// JWT claims for service-to-service authentication.
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -26,7 +37,7 @@ struct Claims {
|
|||
}
|
||||
|
||||
/// A single authentication strategy resolved at startup.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AuthStrategy {
|
||||
Jwt {
|
||||
key: Arc<DecodingKey>,
|
||||
|
|
@ -45,7 +56,7 @@ pub fn jwt_validation() -> Validation {
|
|||
}
|
||||
|
||||
/// Authentication mode resolved at startup.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AuthMode {
|
||||
/// One or more strategies to try in order.
|
||||
Strategies(Vec<AuthStrategy>),
|
||||
|
|
@ -58,84 +69,141 @@ pub enum AuthMode {
|
|||
pub struct PeerCertificates(pub Option<Vec<CertificateDer<'static>>>);
|
||||
|
||||
/// Decode a PEM env var that may be raw PEM or base64-encoded PEM.
|
||||
pub fn decode_pem_env(name: &str, value: &str) -> String {
|
||||
pub fn decode_pem_env(name: &str, value: &str) -> Result<String> {
|
||||
if value.starts_with("-----") {
|
||||
return value.to_string();
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
let bytes = base64::Engine::decode(&BASE64_STANDARD, value)
|
||||
.unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}"));
|
||||
String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}"))
|
||||
.map_err(|e| anyhow!("{name} is not valid PEM or base64: {e}"))?;
|
||||
String::from_utf8(bytes).map_err(|e| anyhow!("{name} base64 decoded to invalid UTF-8: {e}"))
|
||||
}
|
||||
|
||||
/// Resolve the authentication mode from the API config section.
|
||||
/// Resolve the authentication mode from a [`SettingsFile`].
|
||||
///
|
||||
/// Call this once at startup before serving requests. Panics if the
|
||||
/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config).
|
||||
pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String]) -> AuthMode {
|
||||
resolve_auth_mode_with_lookup(api_settings, allowed_usernames, |name| {
|
||||
std::env::var(name).ok()
|
||||
})
|
||||
/// Call this once at startup before serving requests. Returns
|
||||
/// [`AuthMode::Disabled`] when [`FABRO_LOCAL_NO_AUTH_ENV`] is set to `"1"`
|
||||
/// (explicit insecure-startup opt-in). Returns `AuthMode::Strategies(...)`
|
||||
/// when `server.auth` resolves to at least one enabled strategy.
|
||||
///
|
||||
/// Fails closed when `server.auth` is absent or resolves to zero enabled
|
||||
/// strategies, or when a configured strategy is missing its required
|
||||
/// material (JWT public key, mTLS TLS config): startup refuses rather
|
||||
/// than silently accepting every request or panicking the binary.
|
||||
///
|
||||
/// Walks the v2 `server.auth.api.{jwt,mtls}` subtree and
|
||||
/// `server.auth.web.allowed_usernames`.
|
||||
pub fn resolve_auth_mode(settings: &SettingsFile) -> Result<AuthMode> {
|
||||
resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
pub fn resolve_auth_mode_with_lookup<F>(
|
||||
api_settings: &ApiSettings,
|
||||
allowed_usernames: &[String],
|
||||
lookup: F,
|
||||
) -> AuthMode
|
||||
/// Describes which API auth strategies are enabled in a `SettingsFile`.
|
||||
struct ResolvedAuthStrategies {
|
||||
jwt_enabled: bool,
|
||||
mtls_enabled: bool,
|
||||
tls_present: bool,
|
||||
allowed_usernames: Vec<String>,
|
||||
}
|
||||
|
||||
fn resolve_auth_strategies(settings: &SettingsFile) -> ResolvedAuthStrategies {
|
||||
let server = settings.server.as_ref();
|
||||
let auth = server.and_then(|s| s.auth.as_ref());
|
||||
let auth_api = auth.and_then(|a| a.api.as_ref());
|
||||
|
||||
// Strategies: a subtree with `enabled = false` is explicitly off.
|
||||
// Presence of the subtree with `enabled` unset counts as on.
|
||||
let jwt_enabled = auth_api
|
||||
.and_then(|api| api.jwt.as_ref())
|
||||
.is_some_and(|jwt| jwt.enabled.unwrap_or(true));
|
||||
let mtls_enabled = auth_api
|
||||
.and_then(|api| api.mtls.as_ref())
|
||||
.is_some_and(|mtls| mtls.enabled.unwrap_or(true));
|
||||
|
||||
let tls_present = TlsSettings::from_settings(settings).is_some();
|
||||
|
||||
let allowed_usernames = auth
|
||||
.and_then(|a| a.web.as_ref())
|
||||
.map(|w| w.allowed_usernames.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
ResolvedAuthStrategies {
|
||||
jwt_enabled,
|
||||
mtls_enabled,
|
||||
tls_present,
|
||||
allowed_usernames,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_auth_mode_with_lookup<F>(settings: &SettingsFile, lookup: F) -> Result<AuthMode>
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
use fabro_config::server::ApiAuthStrategy;
|
||||
|
||||
if api_settings.authentication_strategies.is_empty()
|
||||
&& std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1")
|
||||
{
|
||||
if lookup(FABRO_LOCAL_NO_AUTH_ENV).as_deref() == Some("1") {
|
||||
warn!(
|
||||
"No authentication strategies configured; allowing unauthenticated local daemon access"
|
||||
"{FABRO_LOCAL_NO_AUTH_ENV}=1 set; allowing unauthenticated local daemon access. \
|
||||
Do not use this flag outside local development or demo environments."
|
||||
);
|
||||
return AuthMode::Disabled;
|
||||
return Ok(AuthMode::Disabled);
|
||||
}
|
||||
|
||||
if api_settings.authentication_strategies.is_empty() {
|
||||
warn!("No authentication strategies configured; all requests will be rejected");
|
||||
}
|
||||
let ResolvedAuthStrategies {
|
||||
jwt_enabled,
|
||||
mtls_enabled,
|
||||
tls_present,
|
||||
allowed_usernames,
|
||||
} = resolve_auth_strategies(settings);
|
||||
|
||||
let mut strategies = Vec::new();
|
||||
if lookup("SESSION_SECRET").is_some() {
|
||||
strategies.push(AuthStrategy::Cookie);
|
||||
}
|
||||
|
||||
strategies.extend(api_settings
|
||||
.authentication_strategies
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
ApiAuthStrategy::Jwt => {
|
||||
let raw = lookup("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|| {
|
||||
panic!(
|
||||
"FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM \
|
||||
format (or base64-encoded PEM) for JWT authentication."
|
||||
)
|
||||
});
|
||||
let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw);
|
||||
let key = DecodingKey::from_ed_pem(pem.as_bytes())
|
||||
.expect("FABRO_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key");
|
||||
AuthStrategy::Jwt {
|
||||
key: Arc::new(key),
|
||||
validation: Arc::new(jwt_validation()),
|
||||
allowed_usernames: allowed_usernames.to_vec(),
|
||||
}
|
||||
}
|
||||
ApiAuthStrategy::Mtls => {
|
||||
assert!(
|
||||
api_settings.tls.is_some(),
|
||||
"mTLS authentication strategy requires [api.tls] configuration with cert, key, and ca"
|
||||
);
|
||||
AuthStrategy::Mtls
|
||||
}
|
||||
}));
|
||||
if jwt_enabled {
|
||||
let raw = lookup("FABRO_JWT_PUBLIC_KEY").ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Fabro server refuses to start: [server.auth.api.jwt] is enabled but \
|
||||
FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM format \
|
||||
(or base64-encoded PEM) for JWT authentication."
|
||||
)
|
||||
})?;
|
||||
let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw)?;
|
||||
let key = DecodingKey::from_ed_pem(pem.as_bytes()).map_err(|e| {
|
||||
anyhow!(
|
||||
"Fabro server refuses to start: FABRO_JWT_PUBLIC_KEY contains an invalid \
|
||||
Ed25519 PEM public key: {e}"
|
||||
)
|
||||
})?;
|
||||
strategies.push(AuthStrategy::Jwt {
|
||||
key: Arc::new(key),
|
||||
validation: Arc::new(jwt_validation()),
|
||||
allowed_usernames: allowed_usernames.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
AuthMode::Strategies(strategies)
|
||||
if mtls_enabled {
|
||||
if !tls_present {
|
||||
return Err(anyhow!(
|
||||
"Fabro server refuses to start: [server.auth.api.mtls] is enabled but \
|
||||
[server.listen.tls] is missing required cert, key, or ca paths."
|
||||
));
|
||||
}
|
||||
strategies.push(AuthStrategy::Mtls);
|
||||
}
|
||||
|
||||
if strategies.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Fabro server refuses to start: no authentication strategies are configured.\n\
|
||||
\n\
|
||||
Configure at least one of the following in `[server.auth]`:\n\
|
||||
- `[server.auth.api.jwt]` (requires `FABRO_JWT_PUBLIC_KEY` env)\n\
|
||||
- `[server.auth.api.mtls]` (requires `[server.listen.tls]` cert/key/ca)\n\
|
||||
- `SESSION_SECRET` env (enables cookie-based web auth)\n\
|
||||
\n\
|
||||
Or set `{FABRO_LOCAL_NO_AUTH_ENV}=1` to explicitly opt in to \
|
||||
unauthenticated local daemon access."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AuthMode::Strategies(strategies))
|
||||
}
|
||||
|
||||
/// Extract the login from JWT claims.
|
||||
|
|
@ -382,10 +450,161 @@ mod tests {
|
|||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use fabro_config::ConfigLayer;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::web_auth::SessionCookie;
|
||||
|
||||
// --- Fail-closed resolver tests (R52/R53) -----------------------------------
|
||||
|
||||
fn settings(source: &str) -> SettingsFile {
|
||||
ConfigLayer::parse(source)
|
||||
.expect("fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Lookup closure that returns nothing — every env var is absent.
|
||||
fn empty_lookup(_name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_when_server_auth_absent() {
|
||||
let file = settings("_version = 1\n");
|
||||
let err =
|
||||
resolve_auth_mode_with_lookup(&file, empty_lookup).expect_err("should refuse startup");
|
||||
assert!(err.to_string().contains("refuses to start"));
|
||||
assert!(err.to_string().contains("FABRO_LOCAL_NO_AUTH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_when_all_strategies_disabled() {
|
||||
let file = settings(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = false
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
let err =
|
||||
resolve_auth_mode_with_lookup(&file, empty_lookup).expect_err("should refuse startup");
|
||||
assert!(err.to_string().contains("no authentication strategies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_insecure_startup_via_env() {
|
||||
let file = settings("_version = 1\n");
|
||||
let mode = resolve_auth_mode_with_lookup(&file, |name| {
|
||||
(name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "1".to_string())
|
||||
})
|
||||
.expect("FABRO_LOCAL_NO_AUTH=1 should allow startup");
|
||||
assert!(matches!(mode, AuthMode::Disabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insecure_startup_flag_any_other_value_still_fails_closed() {
|
||||
let file = settings("_version = 1\n");
|
||||
let err = resolve_auth_mode_with_lookup(&file, |name| {
|
||||
(name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "true".to_string())
|
||||
})
|
||||
.expect_err("only the literal string \"1\" opts in");
|
||||
assert!(err.to_string().contains("refuses to start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_strategy_alone_unlocks_startup() {
|
||||
let file = settings("_version = 1\n");
|
||||
let mode = resolve_auth_mode_with_lookup(&file, |name| {
|
||||
(name == "SESSION_SECRET").then(|| "deadbeef".to_string())
|
||||
})
|
||||
.expect("SESSION_SECRET alone should unlock startup");
|
||||
let AuthMode::Strategies(strategies) = mode else {
|
||||
panic!("expected Strategies, got Disabled");
|
||||
};
|
||||
assert_eq!(strategies.len(), 1);
|
||||
assert!(matches!(strategies[0], AuthStrategy::Cookie));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtls_strategy_resolves_when_enabled_with_listen_tls() {
|
||||
let file = settings(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:3000"
|
||||
|
||||
[server.listen.tls]
|
||||
cert = "/etc/fabro/tls/cert.pem"
|
||||
key = "/etc/fabro/tls/key.pem"
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
"#,
|
||||
);
|
||||
let mode =
|
||||
resolve_auth_mode_with_lookup(&file, empty_lookup).expect("mTLS config should resolve");
|
||||
let AuthMode::Strategies(strategies) = mode else {
|
||||
panic!("expected Strategies, got Disabled");
|
||||
};
|
||||
assert!(strategies.iter().any(|s| matches!(s, AuthStrategy::Mtls)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_when_jwt_enabled_without_public_key_env() {
|
||||
let file = settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
",
|
||||
);
|
||||
let err = resolve_auth_mode_with_lookup(&file, empty_lookup)
|
||||
.expect_err("missing FABRO_JWT_PUBLIC_KEY should refuse startup");
|
||||
assert!(err.to_string().contains("FABRO_JWT_PUBLIC_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_when_jwt_public_key_is_invalid_pem() {
|
||||
let file = settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
",
|
||||
);
|
||||
let err = resolve_auth_mode_with_lookup(&file, |name| {
|
||||
(name == "FABRO_JWT_PUBLIC_KEY").then(|| {
|
||||
"-----BEGIN PUBLIC KEY-----\ngarbage\n-----END PUBLIC KEY-----".to_string()
|
||||
})
|
||||
})
|
||||
.expect_err("invalid PEM should refuse startup");
|
||||
assert!(err.to_string().contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_when_mtls_enabled_without_listen_tls() {
|
||||
let file = settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
",
|
||||
);
|
||||
let err = resolve_auth_mode_with_lookup(&file, empty_lookup)
|
||||
.expect_err("mTLS without [server.listen.tls] should refuse startup");
|
||||
assert!(err.to_string().contains("server.listen.tls"));
|
||||
}
|
||||
|
||||
async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse {
|
||||
"ok"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ mod run_manifest;
|
|||
pub mod secret_store;
|
||||
pub mod serve;
|
||||
pub mod server;
|
||||
mod settings_view;
|
||||
pub mod static_files;
|
||||
pub mod server_config {
|
||||
pub use fabro_config::server::*;
|
||||
pub use fabro_types::Settings;
|
||||
}
|
||||
pub mod tls;
|
||||
pub mod tls_config;
|
||||
pub mod web_auth;
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@ use fabro_config::ConfigLayer;
|
|||
use fabro_config::effective_settings;
|
||||
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
|
||||
use fabro_config::project::resolve_working_directory;
|
||||
use fabro_config::run::{LlmConfig, parse_run_config};
|
||||
use fabro_config::sandbox::{DockerfileSource, SandboxConfig};
|
||||
use fabro_config::run::parse_run_config;
|
||||
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
|
||||
use fabro_graphviz::render::apply_direction;
|
||||
use fabro_llm::Provider;
|
||||
use fabro_model::Catalog;
|
||||
use fabro_sandbox::config::bridge_sandbox;
|
||||
use fabro_sandbox::daytona::DaytonaConfig;
|
||||
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec};
|
||||
use fabro_types::{RunId, Settings};
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode,
|
||||
RunModelLayer, RunSandboxLayer,
|
||||
};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_validate::Severity;
|
||||
use fabro_workflow::error::FabroError;
|
||||
|
|
@ -32,7 +39,7 @@ pub(crate) struct PreparedManifest {
|
|||
pub git: Option<types::ManifestGit>,
|
||||
pub root_source: String,
|
||||
pub run_id: Option<RunId>,
|
||||
pub settings: Settings,
|
||||
pub settings: SettingsFile,
|
||||
pub target_path: PathBuf,
|
||||
pub workflow_bundle: WorkflowBundle,
|
||||
pub workflow_input: BundledWorkflow,
|
||||
|
|
@ -40,7 +47,7 @@ pub(crate) struct PreparedManifest {
|
|||
}
|
||||
|
||||
pub(crate) fn prepare_manifest_with_mode(
|
||||
server_settings: &Settings,
|
||||
server_settings: &SettingsFile,
|
||||
manifest: &types::RunManifest,
|
||||
local_daemon_mode: bool,
|
||||
) -> Result<PreparedManifest> {
|
||||
|
|
@ -83,8 +90,11 @@ pub(crate) fn prepare_manifest_with_mode(
|
|||
},
|
||||
)?;
|
||||
if let Some(goal) = manifest.goal.as_ref() {
|
||||
settings.goal = Some(goal.text.clone());
|
||||
settings.goal_file = None;
|
||||
let run = settings.run.get_or_insert_with(RunLayer::default);
|
||||
// The CLI has already resolved any goal-file reads into
|
||||
// `manifest.goal.text`, so the server side always stores the
|
||||
// final text inline.
|
||||
run.goal = Some(RunGoalLayer::Inline(InterpString::parse(&goal.text)));
|
||||
}
|
||||
|
||||
Ok(PreparedManifest {
|
||||
|
|
@ -200,7 +210,7 @@ fn parse_manifest_config(config: &types::ManifestConfig) -> Result<ConfigLayer>
|
|||
let Some(source) = config.source.as_deref() else {
|
||||
return Ok(ConfigLayer::default());
|
||||
};
|
||||
toml::from_str(source).map_err(Into::into)
|
||||
ConfigLayer::parse(source)
|
||||
}
|
||||
|
||||
fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
|
||||
|
|
@ -208,28 +218,61 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
|
|||
return ConfigLayer::default();
|
||||
};
|
||||
|
||||
let llm = (args.model.is_some() || args.provider.is_some()).then(|| LlmConfig {
|
||||
model: args.model.clone(),
|
||||
provider: args.provider.clone(),
|
||||
fallbacks: None,
|
||||
let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer {
|
||||
provider: args.provider.as_deref().map(InterpString::parse),
|
||||
name: args.model.as_deref().map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox =
|
||||
(args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| SandboxConfig {
|
||||
(args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| RunSandboxLayer {
|
||||
provider: args.sandbox.clone(),
|
||||
preserve: args.preserve_sandbox,
|
||||
..Default::default()
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
ConfigLayer {
|
||||
llm,
|
||||
let execution_has_any =
|
||||
args.dry_run.is_some() || args.auto_approve.is_some() || args.no_retro.is_some();
|
||||
let execution = execution_has_any.then(|| RunExecutionLayer {
|
||||
mode: args
|
||||
.dry_run
|
||||
.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: args.auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
retros: args.no_retro.map(|nr| !nr),
|
||||
});
|
||||
|
||||
let run_has_any =
|
||||
model.is_some() || sandbox.is_some() || execution.is_some() || !args.label.is_empty();
|
||||
|
||||
let run = run_has_any.then(|| RunLayer {
|
||||
model,
|
||||
sandbox,
|
||||
verbose: args.verbose,
|
||||
dry_run: args.dry_run,
|
||||
auto_approve: args.auto_approve,
|
||||
no_retro: args.no_retro,
|
||||
labels: parse_labels(&args.label),
|
||||
..Default::default()
|
||||
}
|
||||
execution,
|
||||
metadata: parse_labels(&args.label),
|
||||
..RunLayer::default()
|
||||
});
|
||||
|
||||
// Verbose is a CLI output concern in v2; route it through cli.output.verbosity.
|
||||
let cli = args.verbose.and_then(|verbose| {
|
||||
verbose.then(|| CliLayer {
|
||||
output: Some(CliOutputLayer {
|
||||
verbosity: Some(OutputVerbosity::Verbose),
|
||||
..CliOutputLayer::default()
|
||||
}),
|
||||
..CliLayer::default()
|
||||
})
|
||||
});
|
||||
|
||||
ConfigLayer::from(SettingsFile {
|
||||
run,
|
||||
cli,
|
||||
..SettingsFile::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_labels(labels: &[String]) -> HashMap<String, String> {
|
||||
|
|
@ -246,22 +289,27 @@ fn resolve_manifest_dockerfile(
|
|||
files: &HashMap<PathBuf, String>,
|
||||
) -> Result<()> {
|
||||
let source = layer
|
||||
.sandbox
|
||||
.as_v2_mut()
|
||||
.run
|
||||
.as_mut()
|
||||
.and_then(|run| run.sandbox.as_mut())
|
||||
.and_then(|sandbox| sandbox.daytona.as_mut())
|
||||
.and_then(|daytona| daytona.snapshot.as_mut())
|
||||
.and_then(|snapshot| snapshot.dockerfile.as_mut());
|
||||
let Some(DockerfileSource::Path { path }) = source else {
|
||||
let Some(DaytonaDockerfileLayer::Path { path }) = source else {
|
||||
return Ok(());
|
||||
};
|
||||
let logical_path =
|
||||
normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path)
|
||||
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?;
|
||||
let path_owned = path.clone();
|
||||
let logical_path = normalize_logical_path(
|
||||
config_path.parent().unwrap_or_else(|| Path::new(".")),
|
||||
&path_owned,
|
||||
)
|
||||
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?;
|
||||
let content = files
|
||||
.get(&logical_path)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?;
|
||||
*source.unwrap() = DockerfileSource::Inline(content);
|
||||
*source.unwrap() = DaytonaDockerfileLayer::Inline(content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -294,12 +342,12 @@ async fn build_preflight_report(
|
|||
let settings = &prepared.settings;
|
||||
let sandbox_provider = resolve_sandbox_provider(settings)?;
|
||||
let github_app = state
|
||||
.github_app_credentials(settings.app_id())
|
||||
.github_app_credentials(settings.github_app_id_str().as_deref())
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
let mut checks = Vec::new();
|
||||
|
||||
let setup_command_count = settings.setup_commands().len();
|
||||
let setup_command_count = settings.run_prepare_commands().len();
|
||||
let repo_summary = prepared.git.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|git| {
|
||||
|
|
@ -361,20 +409,19 @@ async fn build_preflight_report(
|
|||
))
|
||||
}
|
||||
|
||||
fn resolve_sandbox_provider(settings: &Settings) -> Result<SandboxProvider> {
|
||||
fn resolve_sandbox_provider(settings: &SettingsFile) -> Result<SandboxProvider> {
|
||||
Ok(settings
|
||||
.sandbox_settings()
|
||||
.and_then(|sandbox| sandbox.provider.as_deref())
|
||||
.run_sandbox()
|
||||
.and_then(|sb| sb.provider.as_deref())
|
||||
.map(str::parse::<SandboxProvider>)
|
||||
.transpose()
|
||||
.map_err(|err| anyhow!("Invalid sandbox provider: {err}"))?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn resolve_daytona_config(settings: &Settings) -> Option<DaytonaConfig> {
|
||||
settings
|
||||
.sandbox_settings()
|
||||
.and_then(|sandbox| sandbox.daytona.clone())
|
||||
fn resolve_daytona_config(settings: &SettingsFile) -> Option<DaytonaConfig> {
|
||||
let sandbox = settings.run_sandbox()?;
|
||||
bridge_sandbox(sandbox).daytona
|
||||
}
|
||||
|
||||
async fn run_sandbox_check(
|
||||
|
|
@ -453,7 +500,7 @@ async fn run_llm_check(
|
|||
state: &AppState,
|
||||
checks: &mut Vec<CheckResult>,
|
||||
graph: &Graph,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
) -> bool {
|
||||
let (model, provider) = resolve_model_provider(settings, graph);
|
||||
let default_provider = provider.as_deref().unwrap_or("anthropic");
|
||||
|
|
@ -544,40 +591,34 @@ async fn run_llm_check(
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option<String>) {
|
||||
let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref());
|
||||
let configured_provider = settings
|
||||
.llm
|
||||
.as_ref()
|
||||
.and_then(|llm| llm.provider.as_deref());
|
||||
fn resolve_model_provider(settings: &SettingsFile, graph: &Graph) -> (String, Option<String>) {
|
||||
let configured_model = settings.run_model_name_str();
|
||||
let configured_provider = settings.run_model_provider_str();
|
||||
|
||||
let provider = configured_provider
|
||||
.or_else(|| {
|
||||
graph
|
||||
.attrs
|
||||
.get("default_provider")
|
||||
.and_then(|value| value.as_str())
|
||||
})
|
||||
.map(String::from);
|
||||
let provider = configured_provider.or_else(|| {
|
||||
graph
|
||||
.attrs
|
||||
.get("default_provider")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(String::from)
|
||||
});
|
||||
let model = configured_model
|
||||
.or_else(|| {
|
||||
graph
|
||||
.attrs
|
||||
.get("default_model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.map_or_else(
|
||||
|| {
|
||||
let catalog = Catalog::builtin();
|
||||
let info = provider
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<Provider>().ok())
|
||||
.and_then(|provider| catalog.default_for_provider(provider))
|
||||
.unwrap_or_else(|| catalog.default_from_env());
|
||||
info.id.clone()
|
||||
},
|
||||
String::from,
|
||||
);
|
||||
.unwrap_or_else(|| {
|
||||
let catalog = Catalog::builtin();
|
||||
let info = provider
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<Provider>().ok())
|
||||
.and_then(|provider| catalog.default_for_provider(provider))
|
||||
.unwrap_or_else(|| catalog.default_from_env());
|
||||
info.id.clone()
|
||||
});
|
||||
|
||||
match Catalog::builtin().get(&model) {
|
||||
Some(info) => (
|
||||
|
|
@ -591,23 +632,30 @@ fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option
|
|||
async fn run_github_token_check(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
prepared: &PreparedManifest,
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
) {
|
||||
let Some(github_permissions) = settings.github_permissions() else {
|
||||
let Some(v2_permissions) = settings.github_permissions() else {
|
||||
return;
|
||||
};
|
||||
if github_permissions.is_empty() {
|
||||
if v2_permissions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve InterpString permission values eagerly for token minting and
|
||||
// for display in the preflight report.
|
||||
let github_permissions: HashMap<String, String> = v2_permissions
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.as_source()))
|
||||
.collect();
|
||||
|
||||
let perm_details = github_permissions
|
||||
.iter()
|
||||
.map(|(key, value)| CheckDetail::new(format!("{key}: {value}")))
|
||||
.collect::<Vec<_>>();
|
||||
match (&github_app, prepared.git.as_ref()) {
|
||||
(Some(creds), Some(git)) => {
|
||||
match mint_github_token(creds, &git.origin_url, github_permissions).await {
|
||||
match mint_github_token(creds, &git.origin_url, &github_permissions).await {
|
||||
Ok(_) => checks.push(CheckResult {
|
||||
name: "GitHub Token".into(),
|
||||
status: CheckStatus::Pass,
|
||||
|
|
@ -766,31 +814,49 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn server_settings_fixture(source: &str) -> SettingsFile {
|
||||
fabro_config::ConfigLayer::parse(source)
|
||||
.expect("v2 fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_manifest_does_not_inherit_server_dry_run_fallback() {
|
||||
let server_settings = Settings {
|
||||
dry_run: Some(true),
|
||||
storage_dir: Some(PathBuf::from("/srv/fabro")),
|
||||
..Default::default()
|
||||
};
|
||||
let server_settings = server_settings_fixture(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.execution]
|
||||
mode = "dry_run"
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
"#,
|
||||
);
|
||||
|
||||
let prepared =
|
||||
prepare_manifest_with_mode(&server_settings, &minimal_manifest(), false).unwrap();
|
||||
|
||||
assert_eq!(prepared.settings.dry_run, None);
|
||||
assert!(!prepared.settings.dry_run_enabled());
|
||||
assert_eq!(
|
||||
prepared.settings.storage_dir,
|
||||
Some(PathBuf::from("/srv/fabro"))
|
||||
prepared.settings.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_manifest_preserves_explicit_manifest_dry_run() {
|
||||
let server_settings = Settings {
|
||||
dry_run: Some(true),
|
||||
storage_dir: Some(PathBuf::from("/srv/fabro")),
|
||||
..Default::default()
|
||||
};
|
||||
let server_settings = server_settings_fixture(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.execution]
|
||||
mode = "dry_run"
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
"#,
|
||||
);
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.args = Some(types::ManifestArgs {
|
||||
auto_approve: None,
|
||||
|
|
@ -806,33 +872,35 @@ mod tests {
|
|||
|
||||
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap();
|
||||
|
||||
assert_eq!(prepared.settings.dry_run, Some(true));
|
||||
assert!(prepared.settings.dry_run_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_manifest_local_daemon_prefers_bundled_settings_without_duplication() {
|
||||
let server_settings: Settings = toml::from_str(
|
||||
let server_settings = server_settings_fixture(
|
||||
r#"
|
||||
storage_dir = "/srv/fabro"
|
||||
_version = 1
|
||||
|
||||
[setup]
|
||||
commands = ["cli-setup"]
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
|
||||
[git]
|
||||
[[run.prepare.steps]]
|
||||
script = "cli-setup"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "snapshotted-app-id"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: "workflow.toml".to_string(),
|
||||
source: r#"
|
||||
version = 1
|
||||
_version = 1
|
||||
|
||||
[setup]
|
||||
commands = ["workflow-setup"]
|
||||
[[run.prepare.steps]]
|
||||
script = "workflow-setup"
|
||||
"#
|
||||
.to_string(),
|
||||
});
|
||||
|
|
@ -840,10 +908,12 @@ commands = ["workflow-setup"]
|
|||
path: Some("/tmp/home/.fabro/settings.toml".to_string()),
|
||||
source: Some(
|
||||
r#"
|
||||
[setup]
|
||||
commands = ["cli-setup"]
|
||||
_version = 1
|
||||
|
||||
[git]
|
||||
[[run.prepare.steps]]
|
||||
script = "cli-setup"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "snapshotted-app-id"
|
||||
"#
|
||||
.to_string(),
|
||||
|
|
@ -853,18 +923,19 @@ app_id = "snapshotted-app-id"
|
|||
|
||||
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, true).unwrap();
|
||||
|
||||
// v2 merge matrix: run.prepare.steps replaces the whole list across
|
||||
// layers, so the higher-precedence workflow layer wins over cli.
|
||||
assert_eq!(
|
||||
prepared
|
||||
.settings
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|setup| setup.commands.clone()),
|
||||
Some(vec!["workflow-setup".to_string(), "cli-setup".to_string(),])
|
||||
prepared.settings.run_prepare_commands(),
|
||||
vec!["workflow-setup".to_string()]
|
||||
);
|
||||
assert_eq!(prepared.settings.app_id(), Some("snapshotted-app-id"));
|
||||
assert_eq!(
|
||||
prepared.settings.storage_dir,
|
||||
Some(PathBuf::from("/srv/fabro"))
|
||||
prepared.settings.github_app_id_str().as_deref(),
|
||||
Some("snapshotted-app-id")
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.settings.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::{Arc, RwLock};
|
|||
use std::time::Duration;
|
||||
|
||||
use fabro_config::Storage;
|
||||
use fabro_config::server::{ArtifactStorageBackend, resolve_storage_dir};
|
||||
use fabro_config::resolve_storage_dir;
|
||||
use fabro_config::user::{active_settings_path, load_settings_config};
|
||||
use fabro_util::terminal::Styles;
|
||||
use object_store::ObjectStore;
|
||||
|
|
@ -17,7 +17,7 @@ use tracing::{error, info, warn};
|
|||
|
||||
use clap::Args;
|
||||
|
||||
use fabro_types::Settings;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
|
||||
use crate::bind::{self, Bind, BindRequest};
|
||||
use crate::github_webhooks::WebhookManager;
|
||||
|
|
@ -28,6 +28,7 @@ use crate::server::{
|
|||
reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler,
|
||||
};
|
||||
use crate::tls::{ClientAuth, build_rustls_config, serve_tls_with_shutdown};
|
||||
use crate::tls_config::TlsSettings;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
|
||||
|
|
@ -80,42 +81,70 @@ pub struct ServeArgs {
|
|||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn load_settings(path: Option<&Path>) -> anyhow::Result<Settings> {
|
||||
load_settings_config(path)?.try_into()
|
||||
fn load_settings(path: Option<&Path>) -> anyhow::Result<SettingsFile> {
|
||||
Ok(load_settings_config(path)?.into())
|
||||
}
|
||||
|
||||
fn resolved_config_path(path: Option<&Path>) -> PathBuf {
|
||||
active_settings_path(path)
|
||||
}
|
||||
|
||||
fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool) -> Settings {
|
||||
fn apply_serve_overrides(
|
||||
base: &SettingsFile,
|
||||
args: &ServeArgs,
|
||||
dry_run_mode: bool,
|
||||
) -> SettingsFile {
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer,
|
||||
};
|
||||
use fabro_types::settings::server::{ServerLayer, ServerWebLayer};
|
||||
let mut settings = base.clone();
|
||||
if dry_run_mode {
|
||||
settings.dry_run = Some(true);
|
||||
let run = settings.run.get_or_insert_with(RunLayer::default);
|
||||
let execution = run.execution.get_or_insert_with(RunExecutionLayer::default);
|
||||
execution.mode = Some(RunMode::DryRun);
|
||||
}
|
||||
if args.web || args.no_web {
|
||||
settings.web.get_or_insert_default().enabled = args.web;
|
||||
let server = settings.server.get_or_insert_with(ServerLayer::default);
|
||||
let web = server.web.get_or_insert_with(ServerWebLayer::default);
|
||||
web.enabled = Some(args.web);
|
||||
}
|
||||
if let Some(ref model) = args.model {
|
||||
settings.llm.get_or_insert_default().model = Some(model.clone());
|
||||
let run = settings.run.get_or_insert_with(RunLayer::default);
|
||||
let model_layer = run.model.get_or_insert_with(RunModelLayer::default);
|
||||
model_layer.name = Some(InterpString::parse(model));
|
||||
}
|
||||
if let Some(ref provider) = args.provider {
|
||||
settings.llm.get_or_insert_default().provider = Some(provider.clone());
|
||||
let run = settings.run.get_or_insert_with(RunLayer::default);
|
||||
let model_layer = run.model.get_or_insert_with(RunModelLayer::default);
|
||||
model_layer.provider = Some(InterpString::parse(provider));
|
||||
}
|
||||
if let Some(sandbox) = args.sandbox {
|
||||
settings.sandbox.get_or_insert_default().provider = Some(sandbox.to_string());
|
||||
let run = settings.run.get_or_insert_with(RunLayer::default);
|
||||
let sandbox_layer = run.sandbox.get_or_insert_with(RunSandboxLayer::default);
|
||||
sandbox_layer.provider = Some(sandbox.to_string());
|
||||
}
|
||||
// CliLayer is namespaced; nothing to populate from flag overrides today.
|
||||
let _ = CliLayer::default();
|
||||
settings
|
||||
}
|
||||
|
||||
fn apply_runtime_settings(
|
||||
base: &Settings,
|
||||
base: &SettingsFile,
|
||||
args: &ServeArgs,
|
||||
dry_run_mode: bool,
|
||||
data_dir: &Path,
|
||||
) -> Settings {
|
||||
) -> SettingsFile {
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::server::{ServerLayer, ServerStorageLayer};
|
||||
let mut settings = apply_serve_overrides(base, args, dry_run_mode);
|
||||
settings.storage_dir = Some(data_dir.to_path_buf());
|
||||
let server = settings.server.get_or_insert_with(ServerLayer::default);
|
||||
let storage = server
|
||||
.storage
|
||||
.get_or_insert_with(ServerStorageLayer::default);
|
||||
storage.root = Some(InterpString::parse(&data_dir.to_string_lossy()));
|
||||
settings
|
||||
}
|
||||
|
||||
|
|
@ -143,38 +172,55 @@ fn build_object_store(store_path: &Path) -> anyhow::Result<Arc<dyn ObjectStore>>
|
|||
}
|
||||
|
||||
fn build_artifact_object_store(
|
||||
settings: &Settings,
|
||||
settings: &SettingsFile,
|
||||
storage: &Storage,
|
||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
|
||||
let artifact_settings = settings.artifact_storage.clone().unwrap_or_default();
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::server::ObjectStoreProvider;
|
||||
|
||||
let artifacts = settings.server_artifacts();
|
||||
let prefix = artifacts
|
||||
.and_then(|a| a.prefix.as_ref())
|
||||
.map_or_else(|| "artifacts".to_string(), InterpString::as_source);
|
||||
|
||||
if use_in_memory_store() {
|
||||
return Ok((Arc::new(InMemory::new()), artifact_settings.prefix));
|
||||
return Ok((Arc::new(InMemory::new()), prefix));
|
||||
}
|
||||
|
||||
match artifact_settings.backend {
|
||||
ArtifactStorageBackend::Local => {
|
||||
let provider = artifacts
|
||||
.and_then(|a| a.provider)
|
||||
.unwrap_or(ObjectStoreProvider::Local);
|
||||
|
||||
let s3_cfg = artifacts.and_then(|a| a.s3.as_ref());
|
||||
match provider {
|
||||
ObjectStoreProvider::Local => {
|
||||
std::fs::create_dir_all(storage.artifact_store_dir())?;
|
||||
let object_store = Arc::new(LocalFileSystem::new_with_prefix(storage.root())?);
|
||||
Ok((object_store, artifact_settings.prefix))
|
||||
Ok((object_store, prefix))
|
||||
}
|
||||
ArtifactStorageBackend::S3 => {
|
||||
let bucket = artifact_settings
|
||||
ObjectStoreProvider::S3 => {
|
||||
let s3 = s3_cfg.ok_or_else(|| {
|
||||
anyhow::anyhow!("server.artifacts.s3 is required for provider = 's3'")
|
||||
})?;
|
||||
let bucket = s3
|
||||
.bucket
|
||||
.ok_or_else(|| anyhow::anyhow!("artifact_storage.bucket is required for s3"))?;
|
||||
let region = artifact_settings
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.ok_or_else(|| anyhow::anyhow!("server.artifacts.s3.bucket is required"))?;
|
||||
let region = s3
|
||||
.region
|
||||
.ok_or_else(|| anyhow::anyhow!("artifact_storage.region is required for s3"))?;
|
||||
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.ok_or_else(|| anyhow::anyhow!("server.artifacts.s3.region is required"))?;
|
||||
let mut builder = AmazonS3Builder::from_env()
|
||||
.with_bucket_name(bucket)
|
||||
.with_region(region)
|
||||
.with_virtual_hosted_style_request(!artifact_settings.path_style.unwrap_or(false));
|
||||
if let Some(endpoint) = artifact_settings.endpoint {
|
||||
.with_virtual_hosted_style_request(!s3.path_style.unwrap_or(false));
|
||||
if let Some(endpoint) = s3.endpoint.as_ref().map(InterpString::as_source) {
|
||||
builder = builder.with_endpoint(endpoint);
|
||||
}
|
||||
let object_store = Arc::new(builder.build()?);
|
||||
Ok((object_store, artifact_settings.prefix))
|
||||
Ok((object_store, prefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -241,32 +287,28 @@ where
|
|||
let shared_settings = Arc::new(RwLock::new(effective_settings));
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let (auth_mode, client_auth, max_concurrent_runs) = {
|
||||
let cfg = shared_settings.read().expect("config lock poisoned");
|
||||
let api = cfg.api.clone().unwrap_or_default();
|
||||
let allowed_usernames = cfg
|
||||
.web
|
||||
.as_ref()
|
||||
.map(|w| w.auth.allowed_usernames.clone())
|
||||
.unwrap_or_default();
|
||||
let auth_mode = resolve_auth_mode_with_lookup(&api, &allowed_usernames, |name| {
|
||||
let cfg_file = shared_settings.read().expect("config lock poisoned");
|
||||
let auth_mode = resolve_auth_mode_with_lookup(&cfg_file, |name| {
|
||||
secret_snapshot
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| std::env::var(name).ok())
|
||||
});
|
||||
let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode));
|
||||
})?;
|
||||
let tls_present = TlsSettings::from_settings(&cfg_file).is_some();
|
||||
let client_auth = tls_present.then(|| client_auth_from_mode(&auth_mode));
|
||||
let max_concurrent_runs = args
|
||||
.max_concurrent_runs
|
||||
.or(cfg.max_concurrent_runs)
|
||||
.or_else(|| cfg_file.max_concurrent_runs())
|
||||
.unwrap_or(5);
|
||||
(auth_mode, client_auth, max_concurrent_runs)
|
||||
};
|
||||
let web_enabled = shared_settings
|
||||
.read()
|
||||
.expect("config lock poisoned")
|
||||
.web
|
||||
.as_ref()
|
||||
.is_none_or(|web| web.enabled);
|
||||
let web_enabled = {
|
||||
let cfg_file = shared_settings.read().expect("config lock poisoned");
|
||||
cfg_file
|
||||
.server_web()
|
||||
.and_then(|w| w.enabled)
|
||||
.unwrap_or(true)
|
||||
};
|
||||
|
||||
let store_path = storage.store_dir();
|
||||
let object_store = build_object_store(&store_path)?;
|
||||
|
|
@ -308,11 +350,13 @@ where
|
|||
|
||||
// Optionally start webhook listener
|
||||
let webhook_app_id = {
|
||||
let cfg = shared_settings.read().expect("config lock poisoned");
|
||||
cfg.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.webhooks.as_ref().and(g.app_id.as_ref()))
|
||||
.cloned()
|
||||
use fabro_types::settings::InterpString;
|
||||
let cfg_file = shared_settings.read().expect("config lock poisoned");
|
||||
cfg_file
|
||||
.server_integrations_github()
|
||||
.filter(|github| github.webhooks.is_some())
|
||||
.and_then(|github| github.app_id.as_ref())
|
||||
.map(InterpString::as_source)
|
||||
};
|
||||
let webhook_manager = match webhook_app_id {
|
||||
Some(app_id) => {
|
||||
|
|
@ -401,12 +445,10 @@ where
|
|||
});
|
||||
|
||||
// Branch: TLS, plain TCP, or Unix socket
|
||||
let tls_settings = shared_settings
|
||||
.read()
|
||||
.expect("config lock poisoned")
|
||||
.api
|
||||
.as_ref()
|
||||
.and_then(|a| a.tls.clone());
|
||||
let tls_settings = {
|
||||
let cfg_file = shared_settings.read().expect("config lock poisoned");
|
||||
TlsSettings::from_settings(&cfg_file)
|
||||
};
|
||||
|
||||
let bound_listener = bind_listener(&bind_request).await?;
|
||||
let bind_addr = bound_listener.bind.clone();
|
||||
|
|
@ -638,11 +680,18 @@ mod tests {
|
|||
build_object_store_with_preference, server_bind_title, server_title,
|
||||
};
|
||||
use crate::bind::Bind;
|
||||
use fabro_types::Settings;
|
||||
use fabro_config::ConfigLayer;
|
||||
use fabro_types::settings::SettingsFile;
|
||||
|
||||
fn parse_settings(source: &str) -> SettingsFile {
|
||||
ConfigLayer::parse(source)
|
||||
.expect("v2 fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_runtime_settings_preserves_storage_dir() {
|
||||
let base = Settings::default();
|
||||
let base = SettingsFile::default();
|
||||
let args = ServeArgs {
|
||||
bind: None,
|
||||
model: None,
|
||||
|
|
@ -659,20 +708,21 @@ mod tests {
|
|||
apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro-storage"));
|
||||
|
||||
assert_eq!(
|
||||
resolved.storage_dir,
|
||||
Some(PathBuf::from("/srv/fabro-storage"))
|
||||
resolved.server_storage_root_str().as_deref(),
|
||||
Some("/srv/fabro-storage")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_runtime_settings_enables_web_from_cli_flag() {
|
||||
let base: Settings = toml::from_str(
|
||||
let base = parse_settings(
|
||||
r#"
|
||||
[web]
|
||||
_version = 1
|
||||
|
||||
[server.web]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
let args = ServeArgs {
|
||||
bind: None,
|
||||
model: None,
|
||||
|
|
@ -687,12 +737,12 @@ enabled = false
|
|||
|
||||
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
|
||||
|
||||
assert!(resolved.web.expect("web settings should exist").enabled);
|
||||
assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_runtime_settings_disables_web_from_cli_flag() {
|
||||
let base = Settings::default();
|
||||
let base = SettingsFile::default();
|
||||
let args = ServeArgs {
|
||||
bind: None,
|
||||
model: None,
|
||||
|
|
@ -707,7 +757,7 @@ enabled = false
|
|||
|
||||
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
|
||||
|
||||
assert!(!resolved.web.expect("web settings should exist").enabled);
|
||||
assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts};
|
|||
use fabro_store::{
|
||||
ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId,
|
||||
};
|
||||
use fabro_types::settings::{InterpString, SettingsFile};
|
||||
use fabro_types::{
|
||||
ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId,
|
||||
RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance,
|
||||
RunSubjectProvenance, Settings,
|
||||
RunSubjectProvenance,
|
||||
};
|
||||
use fabro_util::redact::redact_jsonl_line;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
|
|
@ -76,6 +77,7 @@ use crate::jwt_auth::{
|
|||
};
|
||||
use crate::run_manifest;
|
||||
use crate::secret_store::{SecretStore, SecretStoreError};
|
||||
use crate::settings_view;
|
||||
use crate::static_files;
|
||||
use crate::web_auth;
|
||||
use fabro_interview::{
|
||||
|
|
@ -520,7 +522,7 @@ pub struct AppState {
|
|||
global_event_tx: broadcast::Sender<EventEnvelope>,
|
||||
|
||||
pub(crate) secret_store: AsyncRwLock<SecretStore>,
|
||||
pub(crate) settings: Arc<RwLock<Settings>>,
|
||||
pub(crate) settings: Arc<RwLock<SettingsFile>>,
|
||||
pub(crate) config_path: PathBuf,
|
||||
pub(crate) local_daemon_mode: bool,
|
||||
shutting_down: AtomicBool,
|
||||
|
|
@ -1009,7 +1011,7 @@ fn real_routes() -> Router<Arc<AppState>> {
|
|||
get(get_stage_artifact),
|
||||
)
|
||||
.route("/runs/{id}/billing", get(get_run_billing))
|
||||
.route("/runs/{id}/settings", get(not_implemented))
|
||||
.route("/runs/{id}/settings", get(get_run_settings))
|
||||
.route("/runs/{id}/steer", post(not_implemented))
|
||||
.route("/runs/{id}/preview", post(generate_preview_url))
|
||||
.route("/runs/{id}/ssh", post(create_ssh_access))
|
||||
|
|
@ -1064,20 +1066,16 @@ async fn get_server_settings(
|
|||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
let settings = state.settings.read().unwrap().clone();
|
||||
let response = match api_server_settings(&settings) {
|
||||
Ok(response) => response,
|
||||
let redacted = settings_view::redact_for_api(&settings);
|
||||
let mut value = match serde_json::to_value(&redacted) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
||||
fn api_server_settings(settings: &Settings) -> anyhow::Result<ServerSettings> {
|
||||
let mut value = serde_json::to_value(settings)?;
|
||||
strip_nulls(&mut value);
|
||||
serde_json::from_value(value).map_err(Into::into)
|
||||
(StatusCode::OK, Json(value)).into_response()
|
||||
}
|
||||
|
||||
fn strip_nulls(value: &mut serde_json::Value) {
|
||||
|
|
@ -1416,10 +1414,10 @@ fn build_prune_plan(
|
|||
})
|
||||
}
|
||||
|
||||
fn system_sandbox_provider(settings: &Settings) -> String {
|
||||
fn system_sandbox_provider(settings: &SettingsFile) -> String {
|
||||
settings
|
||||
.sandbox_settings()
|
||||
.and_then(|sandbox| sandbox.provider.clone())
|
||||
.run_sandbox()
|
||||
.and_then(|sb| sb.provider.clone())
|
||||
.unwrap_or_else(|| SandboxProvider::default().to_string())
|
||||
}
|
||||
|
||||
|
|
@ -1613,15 +1611,12 @@ async fn get_github_repo(
|
|||
.read()
|
||||
.expect("settings lock poisoned")
|
||||
.clone();
|
||||
let app_id = match settings.app_id() {
|
||||
Some(app_id) => app_id.to_string(),
|
||||
None => {
|
||||
return ApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"git.app_id is not configured",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(app_id) = settings.github_app_id_str() else {
|
||||
return ApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server.integrations.github.app_id is not configured",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let creds = match state.github_app_credentials(Some(&app_id)).await {
|
||||
|
|
@ -1647,7 +1642,7 @@ async fn get_github_repo(
|
|||
|
||||
let base_url = fabro_github::github_api_base_url();
|
||||
let client = reqwest::Client::new();
|
||||
let install_url = settings.slug().map_or_else(
|
||||
let install_url = settings.github_slug_str().map_or_else(
|
||||
|| format!("https://github.com/organizations/{owner}/settings/installations"),
|
||||
|slug| format!("https://github.com/apps/{slug}/installations/new"),
|
||||
);
|
||||
|
|
@ -1930,7 +1925,7 @@ async fn get_run_billing(
|
|||
|
||||
/// Create an `AppState` with default settings.
|
||||
pub fn create_app_state() -> Arc<AppState> {
|
||||
create_app_state_with_options(Settings::default(), 5)
|
||||
create_app_state_with_options(SettingsFile::default(), 5)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
|
|
@ -1938,14 +1933,14 @@ pub fn create_app_state_with_registry_factory(
|
|||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
create_app_state_with_settings_and_registry_factory(
|
||||
Settings::default(),
|
||||
SettingsFile::default(),
|
||||
registry_factory_override,
|
||||
)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn create_app_state_with_settings_and_registry_factory(
|
||||
settings: Settings,
|
||||
settings: SettingsFile,
|
||||
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
||||
) -> Arc<AppState> {
|
||||
let (store, artifact_store) = test_store_bundle();
|
||||
|
|
@ -1964,7 +1959,7 @@ pub fn create_app_state_with_settings_and_registry_factory(
|
|||
|
||||
/// Create an `AppState` with the given settings and concurrency limit.
|
||||
pub fn create_app_state_with_options(
|
||||
settings: Settings,
|
||||
settings: SettingsFile,
|
||||
max_concurrent_runs: usize,
|
||||
) -> Arc<AppState> {
|
||||
let (store, artifact_store) = test_store_bundle();
|
||||
|
|
@ -1988,7 +1983,7 @@ fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
|
|||
}
|
||||
|
||||
pub fn create_app_state_with_store(
|
||||
settings: Arc<RwLock<Settings>>,
|
||||
settings: Arc<RwLock<SettingsFile>>,
|
||||
max_concurrent_runs: usize,
|
||||
store: Arc<Database>,
|
||||
artifact_store: ArtifactStore,
|
||||
|
|
@ -2007,7 +2002,7 @@ pub fn create_app_state_with_store(
|
|||
}
|
||||
|
||||
pub(crate) fn build_app_state_with_path(
|
||||
settings: Arc<RwLock<Settings>>,
|
||||
settings: Arc<RwLock<SettingsFile>>,
|
||||
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
||||
max_concurrent_runs: usize,
|
||||
store: Arc<Database>,
|
||||
|
|
@ -2021,8 +2016,8 @@ pub(crate) fn build_app_state_with_path(
|
|||
let slack_service = {
|
||||
let settings = settings.read().expect("settings lock poisoned");
|
||||
settings
|
||||
.slack_settings()
|
||||
.and_then(|slack| slack.default_channel.clone())
|
||||
.server_integrations_slack()
|
||||
.and_then(|slack| slack.default_channel.as_ref().map(InterpString::as_source))
|
||||
.and_then(|default_channel| {
|
||||
resolve_slack_credentials().map(|credentials| {
|
||||
Arc::new(SlackService::new(
|
||||
|
|
@ -3578,7 +3573,13 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
}
|
||||
};
|
||||
let github_app = match state
|
||||
.github_app_credentials(persisted.run_record().settings.app_id())
|
||||
.github_app_credentials(
|
||||
persisted
|
||||
.run_record()
|
||||
.settings
|
||||
.github_app_id_str()
|
||||
.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(github_app) => github_app,
|
||||
|
|
@ -4025,6 +4026,47 @@ async fn get_run_status(
|
|||
}
|
||||
}
|
||||
|
||||
async fn get_run_settings(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let run_store = match state.store.open_run_reader(&id).await {
|
||||
Ok(store) => store,
|
||||
Err(fabro_store::StoreError::RunNotFound(_)) => {
|
||||
return ApiError::not_found("Run not found.").into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let run_state = match run_store.state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let Some(run_record) = run_state.run else {
|
||||
return ApiError::not_found("Run not found.").into_response();
|
||||
};
|
||||
let redacted = settings_view::redact_for_api(&run_record.settings);
|
||||
let mut value = match serde_json::to_value(&redacted) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
strip_nulls(&mut value);
|
||||
(StatusCode::OK, Json(value)).into_response()
|
||||
}
|
||||
|
||||
async fn get_questions(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -5874,9 +5916,6 @@ mod tests {
|
|||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use fabro_config::server::{
|
||||
AuthProvider, AuthSettings, GitAuthorSettings, GitProvider, GitSettings, WebSettings,
|
||||
};
|
||||
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
||||
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
|
||||
#[cfg(unix)]
|
||||
|
|
@ -5890,10 +5929,17 @@ mod tests {
|
|||
start -> exit
|
||||
}"#;
|
||||
|
||||
fn dry_run_settings() -> Settings {
|
||||
Settings {
|
||||
dry_run: Some(true),
|
||||
..Default::default()
|
||||
fn dry_run_settings() -> SettingsFile {
|
||||
use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode};
|
||||
SettingsFile {
|
||||
run: Some(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
}),
|
||||
..SettingsFile::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6175,25 +6221,23 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
async fn auth_login_github_redirects_to_github() {
|
||||
let mut settings = Settings::default();
|
||||
settings.web = Some(WebSettings {
|
||||
enabled: true,
|
||||
url: "http://localhost:3000".to_string(),
|
||||
auth: AuthSettings {
|
||||
provider: AuthProvider::Github,
|
||||
allowed_usernames: vec!["brynary".to_string()],
|
||||
},
|
||||
});
|
||||
settings.git = Some(GitSettings {
|
||||
provider: GitProvider::Github,
|
||||
app_id: Some("123".to_string()),
|
||||
client_id: Some("Iv1.testclient".to_string()),
|
||||
slug: Some("fabro".to_string()),
|
||||
author: GitAuthorSettings::default(),
|
||||
webhooks: None,
|
||||
});
|
||||
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "http://localhost:3000"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "123"
|
||||
client_id = "Iv1.testclient"
|
||||
slug = "fabro"
|
||||
"#,
|
||||
)
|
||||
.expect("fixture should parse")
|
||||
.into();
|
||||
let app = build_router(
|
||||
create_app_state_with_options(settings, 5),
|
||||
AuthMode::Disabled,
|
||||
|
|
@ -7319,49 +7363,48 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn start_run_persists_full_settings_snapshot() {
|
||||
let settings = Settings {
|
||||
dry_run: Some(true),
|
||||
llm: Some(fabro_config::run::LlmSettings {
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
provider: Some("anthropic".to_string()),
|
||||
fallbacks: None,
|
||||
}),
|
||||
sandbox: Some(fabro_config::sandbox::SandboxSettings {
|
||||
provider: Some("local".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
hooks: vec![fabro_hooks::HookDefinition {
|
||||
name: Some("snapshot-hook".to_string()),
|
||||
event: fabro_hooks::HookEvent::RunStart,
|
||||
command: Some("echo snapshot".to_string()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: Some(false),
|
||||
timeout_ms: Some(1_000),
|
||||
sandbox: Some(false),
|
||||
}],
|
||||
git: Some(fabro_config::server::GitSettings {
|
||||
app_id: Some("12345".to_string()),
|
||||
author: fabro_config::server::GitAuthorSettings {
|
||||
name: Some("Snapshot Bot".to_string()),
|
||||
email: Some("snapshot@example.com".to_string()),
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
web: Some(fabro_config::server::WebSettings {
|
||||
url: "http://example.test".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
api: Some(fabro_config::server::ApiSettings {
|
||||
base_url: "http://api.example.test".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
log: Some(fabro_config::server::LogSettings {
|
||||
level: Some("debug".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let state = create_app_state_with_options(settings.clone(), 5);
|
||||
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.execution]
|
||||
mode = "dry_run"
|
||||
|
||||
[run.model]
|
||||
provider = "anthropic"
|
||||
name = "claude-sonnet-4-5"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "local"
|
||||
|
||||
[[run.hooks]]
|
||||
name = "snapshot-hook"
|
||||
event = "run_start"
|
||||
command = ["echo", "snapshot"]
|
||||
blocking = false
|
||||
timeout = "1s"
|
||||
sandbox = false
|
||||
|
||||
[run.git.author]
|
||||
name = "Snapshot Bot"
|
||||
email = "snapshot@example.com"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "12345"
|
||||
|
||||
[server.web]
|
||||
url = "http://example.test"
|
||||
|
||||
[server.api]
|
||||
url = "http://api.example.test"
|
||||
|
||||
[server.logging]
|
||||
level = "debug"
|
||||
"#,
|
||||
)
|
||||
.expect("fixture should parse")
|
||||
.into();
|
||||
let state = create_app_state_with_options(settings, 5);
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
|
||||
let req = Request::builder()
|
||||
|
|
@ -7392,11 +7435,26 @@ mod tests {
|
|||
.unwrap()
|
||||
.run
|
||||
.expect("run record should exist");
|
||||
let mut expected_settings = settings;
|
||||
expected_settings.goal = Some("Test".to_string());
|
||||
expected_settings.dry_run = None;
|
||||
|
||||
assert_eq!(run_record.settings, expected_settings);
|
||||
// Server-side `dry_run` default must not override the manifest's intent.
|
||||
// Verify a sampling of the persisted v2 settings.
|
||||
assert_eq!(
|
||||
run_record.settings.run_goal_inline_str().as_deref(),
|
||||
Some("Test"),
|
||||
"goal should be persisted from the manifest"
|
||||
);
|
||||
assert!(
|
||||
!run_record.settings.dry_run_enabled(),
|
||||
"server-local dry_run fallback must not override manifest intent"
|
||||
);
|
||||
assert_eq!(
|
||||
run_record.settings.run_model_name_str().as_deref(),
|
||||
Some("claude-sonnet-4-5"),
|
||||
);
|
||||
assert_eq!(
|
||||
run_record.settings.github_app_id_str().as_deref(),
|
||||
Some("12345"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -7759,13 +7817,19 @@ mod tests {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cancel_during_startup_persists_cancelled_reason() {
|
||||
let settings = Settings {
|
||||
setup: Some(fabro_config::run::SetupSettings {
|
||||
commands: vec!["sleep 5".to_string()],
|
||||
timeout_ms: Some(30_000),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[[run.prepare.steps]]
|
||||
script = "sleep 5"
|
||||
|
||||
[run.prepare]
|
||||
timeout = "30s"
|
||||
"#,
|
||||
)
|
||||
.expect("fixture should parse")
|
||||
.into();
|
||||
let state = create_app_state_with_settings_and_registry_factory(settings, |interviewer| {
|
||||
fabro_workflow::handler::default_registry(interviewer, || None)
|
||||
});
|
||||
|
|
@ -7868,7 +7932,7 @@ mod tests {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrency_limit_respected() {
|
||||
let state = create_app_state_with_options(Settings::default(), 1);
|
||||
let state = create_app_state_with_options(SettingsFile::default(), 1);
|
||||
let app = test_app_with_scheduler(Arc::clone(&state));
|
||||
|
||||
// Create and start two runs with max_concurrent_runs=1
|
||||
|
|
|
|||
237
lib/crates/fabro-server/src/settings_view.rs
Normal file
237
lib/crates/fabro-server/src/settings_view.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! Outward-facing view of [`SettingsFile`] for API responses.
|
||||
//!
|
||||
//! `/api/v1/settings` and `/api/v1/runs/:id/settings` return the server's v2
|
||||
//! [`SettingsFile`] directly as JSON so authenticated clients (the `fabro
|
||||
//! settings` CLI, the web UI) can see the effective configuration. Before
|
||||
//! serialization, this module drops the handful of fields that would leak
|
||||
//! operational secrets or host-specific filesystem layout.
|
||||
//!
|
||||
//! ## What gets dropped
|
||||
//!
|
||||
//! Per the requirements doc (R16, R52, R53, R79–R81) and the Stage 6.6 plan:
|
||||
//!
|
||||
//! - `server.listen` — the whole subtree. Bind address reveals network
|
||||
//! topology; `[server.listen.tls]` cert/key/ca paths reveal the host
|
||||
//! filesystem layout.
|
||||
//! - `server.auth.api.jwt.issuer` and `jwt.audience` — auth topology. Keeps
|
||||
//! `enabled` so clients can tell whether JWT auth is on.
|
||||
//! - `server.auth.api.mtls.ca` — filesystem path to the CA bundle. Keeps
|
||||
//! `enabled`.
|
||||
//! - `server.auth.web.providers.github.client_secret` — explicit OAuth
|
||||
//! secret. Keeps `enabled` and `client_id` (the latter is public in OAuth).
|
||||
//!
|
||||
//! ## Why that's all
|
||||
//!
|
||||
//! The rest of the v2 tree is either:
|
||||
//!
|
||||
//! - A literal non-secret value (storage root, scheduler limit, integration
|
||||
//! slug, feature flag), OR
|
||||
//! - An [`InterpString`] containing `${env.NAME}` tokens. `InterpString`'s
|
||||
//! default serialization preserves the *unresolved* template form, so the
|
||||
//! wire payload surfaces `"Bearer ${env.TOKEN}"` instead of the resolved
|
||||
//! secret value. No additional redaction pass is needed.
|
||||
//!
|
||||
//! Any future field that carries a raw secret in-band (without env
|
||||
//! interpolation) must be added to the drop list below.
|
||||
|
||||
use fabro_types::settings::SettingsFile;
|
||||
|
||||
/// Build a redacted clone of `settings` safe to serialize outward.
|
||||
///
|
||||
/// See the module docs for the drop-list rationale.
|
||||
#[must_use]
|
||||
pub(crate) fn redact_for_api(settings: &SettingsFile) -> SettingsFile {
|
||||
let mut out = settings.clone();
|
||||
|
||||
if let Some(server) = out.server.as_mut() {
|
||||
// Bind address + TLS key/cert paths: host operational details.
|
||||
server.listen = None;
|
||||
|
||||
if let Some(auth) = server.auth.as_mut() {
|
||||
if let Some(api) = auth.api.as_mut() {
|
||||
if let Some(jwt) = api.jwt.as_mut() {
|
||||
jwt.issuer = None;
|
||||
jwt.audience = None;
|
||||
}
|
||||
if let Some(mtls) = api.mtls.as_mut() {
|
||||
mtls.ca = None;
|
||||
}
|
||||
}
|
||||
if let Some(web) = auth.web.as_mut() {
|
||||
if let Some(providers) = web.providers.as_mut() {
|
||||
if let Some(github) = providers.github.as_mut() {
|
||||
github.client_secret = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_config::ConfigLayer;
|
||||
|
||||
fn parse(source: &str) -> SettingsFile {
|
||||
ConfigLayer::parse(source)
|
||||
.expect("fixture should parse")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_server_listen_entirely() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.listen]
|
||||
type = "tcp"
|
||||
address = "127.0.0.1:32276"
|
||||
|
||||
[server.listen.tls]
|
||||
cert = "/etc/fabro/tls/cert.pem"
|
||||
key = "/etc/fabro/tls/key.pem"
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
"#,
|
||||
);
|
||||
let redacted = redact_for_api(&settings);
|
||||
assert!(redacted.server.unwrap().listen.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_jwt_issuer_and_audience_but_keeps_enabled() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.jwt]
|
||||
enabled = true
|
||||
issuer = "https://auth.example.com"
|
||||
audience = "fabro"
|
||||
"#,
|
||||
);
|
||||
let redacted = redact_for_api(&settings);
|
||||
let jwt = redacted
|
||||
.server
|
||||
.unwrap()
|
||||
.auth
|
||||
.unwrap()
|
||||
.api
|
||||
.unwrap()
|
||||
.jwt
|
||||
.unwrap();
|
||||
assert_eq!(jwt.enabled, Some(true));
|
||||
assert!(jwt.issuer.is_none());
|
||||
assert!(jwt.audience.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_mtls_ca_path_but_keeps_enabled() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth.api.mtls]
|
||||
enabled = true
|
||||
ca = "/etc/fabro/tls/ca.pem"
|
||||
"#,
|
||||
);
|
||||
let redacted = redact_for_api(&settings);
|
||||
let mtls = redacted
|
||||
.server
|
||||
.unwrap()
|
||||
.auth
|
||||
.unwrap()
|
||||
.api
|
||||
.unwrap()
|
||||
.mtls
|
||||
.unwrap();
|
||||
assert_eq!(mtls.enabled, Some(true));
|
||||
assert!(mtls.ca.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_github_client_secret_but_keeps_client_id_and_enabled() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth.web.providers.github]
|
||||
enabled = true
|
||||
client_id = "Iv1.abcdef"
|
||||
client_secret = "${env.GITHUB_OAUTH_SECRET}"
|
||||
"#,
|
||||
);
|
||||
let redacted = redact_for_api(&settings);
|
||||
let github = redacted
|
||||
.server
|
||||
.unwrap()
|
||||
.auth
|
||||
.unwrap()
|
||||
.web
|
||||
.unwrap()
|
||||
.providers
|
||||
.unwrap()
|
||||
.github
|
||||
.unwrap();
|
||||
assert_eq!(github.enabled, Some(true));
|
||||
assert!(github.client_id.is_some());
|
||||
assert!(github.client_secret.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_run_cli_project_and_features() {
|
||||
let settings = parse(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[project]
|
||||
name = "Fabro"
|
||||
|
||||
[run]
|
||||
goal = "ship it"
|
||||
|
||||
[run.model]
|
||||
provider = "anthropic"
|
||||
name = "sonnet"
|
||||
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[features]
|
||||
session_sandboxes = true
|
||||
|
||||
[server.scheduler]
|
||||
max_concurrent_runs = 9
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro"
|
||||
|
||||
[server.integrations.github]
|
||||
app_id = "12345"
|
||||
client_id = "Iv1.abcdef"
|
||||
slug = "fabro-app"
|
||||
"#,
|
||||
);
|
||||
let redacted = redact_for_api(&settings);
|
||||
assert!(redacted.project.is_some());
|
||||
let run = redacted.run.unwrap();
|
||||
assert!(run.goal.is_some());
|
||||
assert!(run.model.is_some());
|
||||
assert!(redacted.cli.is_some());
|
||||
assert!(redacted.features.is_some());
|
||||
let server = redacted.server.unwrap();
|
||||
assert_eq!(
|
||||
server.scheduler.and_then(|s| s.max_concurrent_runs),
|
||||
Some(9)
|
||||
);
|
||||
assert!(server.storage.is_some());
|
||||
let github = server.integrations.unwrap().github.unwrap();
|
||||
assert!(github.app_id.is_some());
|
||||
assert!(github.client_id.is_some());
|
||||
assert!(github.slug.is_some());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue