diff --git a/Cargo.lock b/Cargo.lock index 7986ddaa5..b023b8c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 90331a6f7..1d4a37b24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/fabro-web/app/lib/workflow-api.ts b/apps/fabro-web/app/lib/workflow-api.ts index abad9388a..e73d79ed0 100644 --- a/apps/fabro-web/app/lib/workflow-api.ts +++ b/apps/fabro-web/app/lib/workflow-api.ts @@ -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; export interface WorkflowScheduleSummary { expression: string; diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index b29928e04..59d5d0f7b 100644 --- a/apps/fabro-web/app/routes/run-settings.tsx +++ b/apps/fabro-web/app/routes/run-settings.tsx @@ -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 }; diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 4323e6c60..9feecd60a 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -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; export function meta({}: any) { return [{ title: "Settings — Fabro" }]; diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index 735a7ca23..8ea83f45f 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -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`. export const workflowData: Record = { fix_build: { name: "Fix Build", @@ -22,17 +24,18 @@ export const workflowData: Record = { 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 = { 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 = { 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 = { 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" }, + }, }, }, }, diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 9156ecc02..3c0f575ee 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -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]`. 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. diff --git a/docs/agents/mcp.mdx b/docs/agents/mcp.mdx index 885564db4..f6d74d057 100644 --- a/docs/agents/mcp.mdx +++ b/docs/agents/mcp.mdx @@ -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: diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index d7ee6f200..1992a517b 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -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: diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index e3ab95f15..45d0b12cb 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -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 diff --git a/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md new file mode 100644 index 000000000..412981a1e --- /dev/null +++ b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md @@ -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: `/workflows//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.]` 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.]`. +- 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.]`; 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.` | 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.` | merge by field | +| `run.prepare.steps` | replace whole ordered list | +| `run.notifications.` | merge by field | +| `run.notifications..` | merge by field | +| `run.agent.mcps.` | 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.` | merge by field | +| `server.auth.web.providers.` | merge by field | +| `server.storage` | merge by field | +| `server.artifacts` | merge by field | +| `server.artifacts.` | merge by field | +| `server.slatedb` | merge by field | +| `server.slatedb.` | 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. diff --git a/docs/core-concepts/models.mdx b/docs/core-concepts/models.mdx index 5ad270cd6..7ea0f25f5 100644 --- a/docs/core-concepts/models.mdx +++ b/docs/core-concepts/models.mdx @@ -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. The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. diff --git a/docs/execution/devcontainers.mdx b/docs/execution/devcontainers.mdx index 6db34fb0f..c98e8bbf3 100644 --- a/docs/execution/devcontainers.mdx +++ b/docs/execution/devcontainers.mdx @@ -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 ``` diff --git a/docs/execution/environments.mdx b/docs/execution/environments.mdx index f58adc377..ca70264e2 100644 --- a/docs/execution/environments.mdx +++ b/docs/execution/environments.mdx @@ -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"] } ``` diff --git a/docs/execution/failures.mdx b/docs/execution/failures.mdx index d820951f7..5a219da33 100644 --- a/docs/execution/failures.mdx +++ b/docs/execution/failures.mdx @@ -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 diff --git a/docs/execution/retros.mdx b/docs/execution/retros.mdx index 44471a74a..e52a6c355 100644 --- a/docs/execution/retros.mdx +++ b/docs/execution/retros.mdx @@ -4,7 +4,7 @@ description: "Automatic retrospectives that analyze every workflow run" --- -**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. 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 ``` diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx index 9e6c9e1f2..3f855f8fb 100644 --- a/docs/execution/run-configuration.mdx +++ b/docs/execution/run-configuration.mdx @@ -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 | -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. ### 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: diff --git a/docs/human-tools/ssh-access.mdx b/docs/human-tools/ssh-access.mdx index 7683148e4..41fd8934f 100644 --- a/docs/human-tools/ssh-access.mdx +++ b/docs/human-tools/ssh-access.mdx @@ -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 ``` diff --git a/docs/integrations/daytona.mdx b/docs/integrations/daytona.mdx index 01fa8e2b4..52a875b8a 100644 --- a/docs/integrations/daytona.mdx +++ b/docs/integrations/daytona.mdx @@ -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 ``` diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index ee8034c12..da7fad4e8 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -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 | diff --git a/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md new file mode 100644 index 000000000..c20894061 --- /dev/null +++ b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md @@ -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.]` or `[cli.exec.agent.mcps.]`, 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.]` + - `[cli.exec.agent.mcps.]` +- 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.]` + - 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.]` and `[server.slatedb.]` + - `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..]` + - 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.` + - `run.interviews` + - `run.agent` + - `run.agent.mcps.` + - `run.hooks` + - `run.scm` + - `run.scm.` + - `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.` + - `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.` + - `server.storage` + - `server.artifacts` + - `server.slatedb` + - `server.scheduler` + - `server.logging` + - `server.integrations.` +- 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..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.*.` 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. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md new file mode 100644 index 000000000..eb31b743e --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md @@ -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 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 ;` 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>` 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 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.` 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. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md new file mode 100644 index 000000000..f277a9d55 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md @@ -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`. +- 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::(...)`. +- `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` and `type RunSettings = Record` 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. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md new file mode 100644 index 000000000..17610bb3f --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md @@ -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. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md new file mode 100644 index 000000000..4a4206125 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md @@ -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 { + 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 ` + `cargo nextest run -p ` 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 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 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::::...` 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.*.`, 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 ` 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` and + `#[serde(flatten)] hook_type: Option`, 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.` 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. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index a85d5302e..b9bcb993d 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -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. diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx index c15c44157..e12555b02 100644 --- a/docs/reference/user-configuration.mdx +++ b/docs/reference/user-configuration.mdx @@ -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` | `[]` | -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. -## `[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. diff --git a/docs/workflows/variables.mdx b/docs/workflows/variables.mdx index 345375ec3..c0a0d72bb 100644 --- a/docs/workflows/variables.mdx +++ b/docs/workflows/variables.mdx @@ -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. diff --git a/fabro.toml b/fabro.toml index e823e6866..52c92cce5 100644 --- a/fabro.toml +++ b/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 diff --git a/fabro/workflows/gh-triage/workflow.toml b/fabro/workflows/gh-triage/workflow.toml index 3e0093cb8..8fafbcab1 100644 --- a/fabro/workflows/gh-triage/workflow.toml +++ b/fabro/workflows/gh-triage/workflow.toml @@ -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" diff --git a/fabro/workflows/implement-issue/workflow.toml b/fabro/workflows/implement-issue/workflow.toml index 0ec3f59de..2a0f45f16 100644 --- a/fabro/workflows/implement-issue/workflow.toml +++ b/fabro/workflows/implement-issue/workflow.toml @@ -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" diff --git a/fabro/workflows/implement-plan/workflow.toml b/fabro/workflows/implement-plan/workflow.toml index 2ebc2a05d..9e79c2378 100644 --- a/fabro/workflows/implement-plan/workflow.toml +++ b/fabro/workflows/implement-plan/workflow.toml @@ -1 +1 @@ -version = 1 \ No newline at end of file +_version = 1 diff --git a/fabro/workflows/smoke/workflow.toml b/fabro/workflows/smoke/workflow.toml index d9914dfa6..9e79c2378 100644 --- a/fabro/workflows/smoke/workflow.toml +++ b/fabro/workflows/smoke/workflow.toml @@ -1 +1 @@ -version = 1 +_version = 1 diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index 2657bd3f3..917cd93d8 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -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" } diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 6e754b173..f06d4c9cb 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -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. diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index 857ed5572..a386bc898 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -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), + ) } } diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index 7411ca90d..d1a5ed93f 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -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"), diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 8c33954d0..9eadc5c0b 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -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>, } @@ -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 } diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 24698410b..f81fd3c7b 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -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 { +async fn merged_config(args: &SettingsArgs) -> anyhow::Result { 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 { } 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(()); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 0f57b820c..1d54dc177 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -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 = 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 = 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 diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 4baab4651..14741c588 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -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] diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 1ac56df86..5f55dc607 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -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 diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 9595762ab..8103d6423 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -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()); diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index db6fae923..b4a0a5ee4 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -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() diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 14751ff71..d7b344ea4 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -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 { .collect() } +fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option { + 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, + preserve: Option, +) -> Option { + 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, + auto_approve: Option, + no_retro: Option, +) -> Option { + 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 { + 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> { + 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 { - 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 { - 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() + ); } } diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index c9ec4d919..62e6e8c34 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -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> { 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) } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 7c9658945..5f9c0ea09 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -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"); diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 611f14c04..20d9dd843 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -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"), diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index d507e85c2..491032b6d 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -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]) diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index f1eaddcfd..968212d33 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -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?; diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 12e4129d1..d88eb2599 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -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 { 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> { 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); + } } diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 97b325301..f75c2077e 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -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 Result { 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 { + pub(crate) async fn retrieve_server_settings(&self) -> Result { 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::(raw) + .context("server returned a settings payload that does not match the v2 schema") } pub(crate) async fn create_run_from_manifest( diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 18a2c10c8..28a0391af 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -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 { +pub(crate) fn load_settings() -> anyhow::Result { 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_layer_with_storage_dir(storage_dir)?.resolve() +) -> anyhow::Result { + 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_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve() +) -> anyhow::Result { + 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> { - 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)> { + 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> { + 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) -> Result Result> { 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 { 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> { 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!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 9ba4c9d74..486004aa2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index c9d9a8396..1b7d53a21 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -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 = 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] diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index b707d4026..b4200f118 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -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() ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 14ea203e5..c82db1081 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -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() ), ); diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index 02b444912..67e6b5f19 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -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(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 354935a4b..da25a0145 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo.rs b/lib/crates/fabro-cli/tests/it/cmd/repo.rs index e3ef64050..78b56a56f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo.rs @@ -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] diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index 125802476..5857e5638 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -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"]); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 7930e8506..c502b9fd7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 977859943..96929af26 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -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")) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 894ee01b1..823205294 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -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(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 149076c32..c4c344186 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -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() ), diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs index db88197d3..8fbebe8ec 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs @@ -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/**"] "#, ) diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 3db026f63..ba07e410e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -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]", diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow.rs index 3c81517fd..89143c2aa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow.rs @@ -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(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs index c737206c8..1079604b0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 1deb67413..ce54a6548 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -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 diff --git a/lib/crates/fabro-config/src/combine.rs b/lib/crates/fabro-config/src/combine.rs deleted file mode 100644 index 775d76aca..000000000 --- a/lib/crates/fabro-config/src/combine.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::combine::*; diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 6e87eaee7..b9c066ccb 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -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, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal_file: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, - - // --- Run defaults fields (inlined) --- - #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] - pub work_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub setup: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vars: Option>, - - #[serde(default, skip_serializing_if = "is_default_checkpoint")] - pub checkpoint: CheckpointConfig, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcp_servers: HashMap, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - - // --- User config fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbose: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_check: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dry_run: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_approve: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_retro: Option, - - // --- Server config fields --- - #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] - pub storage_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_storage: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - - // --- Shared fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - - // --- Project config fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fabro: Option, +/// 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 for ConfigLayer { + fn from(file: SettingsFile) -> Self { + Self { file } + } +} + +impl From 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 { + 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 { + 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 { 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 { - 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"); } } diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index f1f499320..c9f59fde6 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -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 { +) -> Result { 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 { - 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 { - 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)); } } diff --git a/lib/crates/fabro-config/src/hook.rs b/lib/crates/fabro-config/src/hook.rs deleted file mode 100644 index 806e3a995..000000000 --- a/lib/crates/fabro-config/src/hook.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::settings::hook::*; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 89b49219e..c3242e773 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -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 diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs deleted file mode 100644 index 26d5aa144..000000000 --- a/lib/crates/fabro-config/src/mcp.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::settings::mcp::*; diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs new file mode 100644 index 000000000..fa662c94d --- /dev/null +++ b/lib/crates/fabro-config/src/merge.rs @@ -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(lower: Option, higher: Option, f: fn(T, T) -> T) -> Option { + 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(lower: Option, higher: Option) -> Option { + higher.or(lower) +} + +fn merge_string_map_replace( + lower: HashMap, + higher: HashMap, +) -> HashMap { + if higher.is_empty() { lower } else { higher } +} + +fn merge_string_map_sticky( + mut lower: HashMap, + higher: HashMap, +) -> HashMap { + 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, + higher: Vec, +) -> Vec { + 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, + higher: HashMap, +) -> HashMap { + 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, higher: Vec) -> Vec { + 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, higher: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(lower.len() + higher.len()); + let mut appended_ids: Vec = 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())); + } +} diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index a2819eb1f..2c49cf426 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -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, -} +const DEFAULT_FABRO_DIRECTORY: &str = "fabro/"; #[derive(Clone, Debug)] pub struct WorkflowPathResolution { @@ -27,40 +27,23 @@ pub struct WorkflowPathResolution { pub workflow_slug: Option, } -fn default_root() -> String { - ".".to_string() -} - -impl From 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 { - 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 { - 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 { } /// 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 { 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 { .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 { 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 { } /// 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)> { 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 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::>() + .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()); } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 5038af905..33fac41c4 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -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, -} - -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 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, - pub draft: Option, - pub auto_merge: Option, - pub merge_strategy: Option, -} - -impl From 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, -} - -impl From 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, -} - -impl From 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, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - -impl From 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, - pub timeout_ms: Option, -} - -impl From 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 { - 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) -> 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 { - 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::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"); + } } diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs deleted file mode 100644 index a90052d45..000000000 --- a/lib/crates/fabro-config/src/sandbox.rs +++ /dev/null @@ -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, - pub labels: Option>, - pub snapshot: Option, - pub network: Option, - /// Skip git repo detection and cloning during initialization. - pub skip_clone: Option, -} - -impl TryFrom for DaytonaSettings { - type Error = anyhow::Error; - - fn try_from(value: DaytonaConfig) -> Result { - 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, - pub cpu: Option, - pub memory: Option, - pub disk: Option, - pub dockerfile: Option, -} - -impl TryFrom for DaytonaSnapshotSettings { - type Error = anyhow::Error; - - fn try_from(value: DaytonaSnapshotConfig) -> Result { - 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, -} - -impl From 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, - pub preserve: Option, - pub devcontainer: Option, - pub local: Option, - pub daytona: Option, - pub env: Option>, -} - -impl TryFrom for SandboxSettings { - type Error = anyhow::Error; - - fn try_from(value: SandboxConfig) -> Result { - 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, - }) - } -} diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs deleted file mode 100644 index aec1d4e25..000000000 --- a/lib/crates/fabro-config/src/server.rs +++ /dev/null @@ -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, - #[serde(default)] - pub allowed_usernames: Vec, -} - -impl From 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, - pub key: Option, - pub ca: Option, -} - -impl TryFrom for TlsSettings { - type Error = anyhow::Error; - - fn try_from(value: TlsConfig) -> Result { - 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, - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, -} - -fn default_base_url() -> String { - "http://localhost:3000/api/v1".to_string() -} - -impl TryFrom for ApiSettings { - type Error = anyhow::Error; - - fn try_from(value: ApiConfig) -> Result { - 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, - pub email: Option, -} - -impl From 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, -} - -impl TryFrom for WebhookSettings { - type Error = anyhow::Error; - - fn try_from(value: WebhookConfig) -> Result { - 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, - pub app_id: Option, - pub client_id: Option, - pub slug: Option, - pub author: Option, - pub webhooks: Option, -} - -impl TryFrom for GitSettings { - type Error = anyhow::Error; - - fn try_from(value: GitConfig) -> Result { - 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, - pub url: Option, - pub auth: Option, -} - -fn default_web_url() -> String { - "http://localhost:3000".to_string() -} - -impl From 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, -} - -impl From 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, - /// Experimental: enable automatic retro generation after workflow runs. - pub retros: Option, -} - -impl From 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, -} - -impl From 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() -} diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs deleted file mode 100644 index 90369ef45..000000000 --- a/lib/crates/fabro-config/src/settings.rs +++ /dev/null @@ -1,54 +0,0 @@ -use fabro_types::Settings; - -use crate::config::ConfigLayer; - -impl TryFrom for Settings { - type Error = anyhow::Error; - - fn try_from(value: ConfigLayer) -> Result { - 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 { - value.clone().try_into() - } -} diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 553c4935c..a979cf93d 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -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>> = OnceLock::new(); -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ClientTlsConfig { - pub cert: Option, - pub key: Option, - pub ca: Option, -} - -impl TryFrom for ClientTlsSettings { - type Error = anyhow::Error; - - fn try_from(value: ClientTlsConfig) -> Result { - 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, - pub tls: Option, -} - -impl TryFrom for ServerSettings { - type Error = anyhow::Error; - - fn try_from(value: ServerConfig) -> Result { - 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, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} - -impl From 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 { 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 } } - 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::load(path) } #[cfg(test)] diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 72f52ca45..392f2e2ba 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -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>, + #[serde(default)] + allowed_env_vars: Vec, + #[serde(default)] + tls: TlsMode, + }, + Prompt { + prompt: String, + model: Option, + }, + Agent { + prompt: String, + model: Option, + max_tool_rounds: Option, + }, +} + +/// A single hook definition. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct HookDefinition { + pub name: Option, + pub event: HookEvent, + /// Inline command shorthand — if set, implies `type = "command"`. + #[serde(default)] + pub command: Option, + /// Explicit hook type (command or http). If omitted and `command` is set, + /// defaults to `Command`. + #[serde(flatten)] + pub hook_type: Option, + /// Regex matched against node_id, handler_type, or event-specific fields. + pub matcher: Option, + /// Override the event's default blocking behavior. + pub blocking: Option, + /// Timeout in milliseconds (default: 60_000). + pub timeout_ms: Option, + /// Run inside the sandbox (true, default) or on the host (false). + pub sandbox: Option, +} + +impl HookDefinition { + /// Resolve the effective hook type: explicit `hook_type` wins, then `command` + /// shorthand, then error. + pub fn resolved_hook_type(&self) -> Option> { + 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, +} + +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 = + std::collections::HashMap::new(); + let mut order: Vec = 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::>() + .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 { + 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() +} diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs index a976bbe84..65ae5001b 100644 --- a/lib/crates/fabro-hooks/src/types.rs +++ b/lib/crates/fabro-hooks/src/types.rs @@ -1,4 +1,4 @@ -pub use fabro_config::hook::HookEvent; +pub use crate::config::HookEvent; use fabro_types::RunId; use serde::{Deserialize, Serialize}; diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/crates/fabro-macros/src/lib.rs index 2ca0fbfce..2d1344ab6 100644 --- a/lib/crates/fabro-macros/src/lib.rs +++ b/lib/crates/fabro-macros/src/lib.rs @@ -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 = diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index 38da06fcb..6c555b818 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -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, + #[serde(default)] + env: HashMap, + }, + Http { + url: String, + #[serde(default)] + headers: HashMap, + }, + /// 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, + port: u16, + #[serde(default)] + env: HashMap, + }, +} + +/// 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) -> HashMap { + 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 = 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 = 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); + } +} diff --git a/lib/crates/fabro-types/src/settings/sandbox.rs b/lib/crates/fabro-sandbox/src/config.rs similarity index 54% rename from lib/crates/fabro-types/src/settings/sandbox.rs rename to lib/crates/fabro-sandbox/src/config.rs index f352f8352..4a6b99180 100644 --- a/lib/crates/fabro-types/src/settings/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/config.rs @@ -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, } -#[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, pub env: Option>, } + +/// 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) +} diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 802fb91e4..c580d6bdb 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -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, }; diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index fec1dd390..cfb14befd 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod sandbox; pub mod sandbox_spec; diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index c05f7aa56..61f9c5aeb 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -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; diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 5b44b0aaf..7581c1477 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index c1144a1dd..11116e8c0 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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() } } diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index eb090eb1f..79161f08f 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -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 { - 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, label: &str| -> Result { + 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()); } } diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 79adaa30e..7b45fff8c 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -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, @@ -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), @@ -58,84 +69,141 @@ pub enum AuthMode { pub struct PeerCertificates(pub Option>>); /// 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 { 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 { + resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok()) } -pub fn resolve_auth_mode_with_lookup( - 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, +} + +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(settings: &SettingsFile, lookup: F) -> Result where F: Fn(&str) -> Option, { - 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 { + 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" } diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 98cf30988..e11d07cdd 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 82b8ced87..d2b6aea35 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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, pub root_source: String, pub run_id: Option, - 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 { @@ -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 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 { @@ -246,22 +289,27 @@ fn resolve_manifest_dockerfile( files: &HashMap, ) -> 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 { +fn resolve_sandbox_provider(settings: &SettingsFile) -> Result { Ok(settings - .sandbox_settings() - .and_then(|sandbox| sandbox.provider.as_deref()) + .run_sandbox() + .and_then(|sb| sb.provider.as_deref()) .map(str::parse::) .transpose() .map_err(|err| anyhow!("Invalid sandbox provider: {err}"))? .unwrap_or_default()) } -fn resolve_daytona_config(settings: &Settings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) +fn resolve_daytona_config(settings: &SettingsFile) -> Option { + 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, 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) { - 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) { + 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::().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::().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, prepared: &PreparedManifest, - settings: &Settings, + settings: &SettingsFile, github_app: Option, ) { - 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 = 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::>(); 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"), ); } } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 9b092cd29..db33402bd 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -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, } -fn load_settings(path: Option<&Path>) -> anyhow::Result { - load_settings_config(path)?.try_into() +fn load_settings(path: Option<&Path>) -> anyhow::Result { + 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> } fn build_artifact_object_store( - settings: &Settings, + settings: &SettingsFile, storage: &Storage, ) -> anyhow::Result<(Arc, 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] diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 63db2baad..3ff4205f8 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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, pub(crate) secret_store: AsyncRwLock, - pub(crate) settings: Arc>, + pub(crate) settings: Arc>, pub(crate) config_path: PathBuf, pub(crate) local_daemon_mode: bool, shutting_down: AtomicBool, @@ -1009,7 +1011,7 @@ fn real_routes() -> Router> { 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>, ) -> 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 { - 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 { - 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) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { 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) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { 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 { let (store, artifact_store) = test_store_bundle(); @@ -1988,7 +1983,7 @@ fn test_store_bundle() -> (Arc, ArtifactStore) { } pub fn create_app_state_with_store( - settings: Arc>, + settings: Arc>, max_concurrent_runs: usize, store: Arc, artifact_store: ArtifactStore, @@ -2007,7 +2002,7 @@ pub fn create_app_state_with_store( } pub(crate) fn build_app_state_with_path( - settings: Arc>, + settings: Arc>, registry_factory_override: Option>, max_concurrent_runs: usize, store: Arc, @@ -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, 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>, + Path(id): Path, +) -> 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>, @@ -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 diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs new file mode 100644 index 000000000..8c1d23f5e --- /dev/null +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -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()); + } +} diff --git a/lib/crates/fabro-server/src/tls.rs b/lib/crates/fabro-server/src/tls.rs index 05733d5d9..a1b226cd1 100644 --- a/lib/crates/fabro-server/src/tls.rs +++ b/lib/crates/fabro-server/src/tls.rs @@ -8,9 +8,8 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tokio::net::TcpListener; use tracing::error; -use fabro_config::server::TlsSettings; - use crate::jwt_auth::PeerCertificates; +use crate::tls_config::TlsSettings; /// How client certificates should be verified. #[derive(Clone, Copy)] diff --git a/lib/crates/fabro-server/src/tls_config.rs b/lib/crates/fabro-server/src/tls_config.rs new file mode 100644 index 000000000..84341813e --- /dev/null +++ b/lib/crates/fabro-server/src/tls_config.rs @@ -0,0 +1,43 @@ +//! Resolved TLS material extracted from `[server.listen.tls]`. +//! +//! Owns the `(cert, key, ca)` triple that the rustls config builder in +//! [`crate::tls`] consumes when the server is listening on TCP with mTLS +//! enabled. + +use std::path::PathBuf; + +use fabro_types::settings::SettingsFile; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::server::ServerListenLayer; + +/// Resolved TLS material used by the rustls config builder in +/// [`crate::tls`] when the server is listening on TCP with +/// `[server.listen.tls]` set. +#[derive(Debug, Clone, PartialEq)] +pub struct TlsSettings { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, +} + +impl TlsSettings { + /// Extract the `[server.listen.tls]` subtree out of a `SettingsFile`. + /// Returns `None` when the server is on Unix sockets, TLS is unset, or + /// any of the three fields is missing. + #[must_use] + pub fn from_settings(file: &SettingsFile) -> Option { + let listen = file.server.as_ref()?.listen.as_ref()?; + let tls = match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref()?, + ServerListenLayer::Unix { .. } => return None, + }; + let cert = tls.cert.as_ref().map(InterpString::as_source)?; + let key = tls.key.as_ref().map(InterpString::as_source)?; + let ca = tls.ca.as_ref().map(InterpString::as_source)?; + Some(Self { + cert: cert.into(), + key: key.into(), + ca: ca.into(), + }) + } +} diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 95ee3bb0f..b13d5d5fb 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -6,8 +6,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, Router, routing::get, routing::post}; use cookie::{Cookie, CookieJar, Expiration, Key, SameSite, time::Duration}; -use fabro_types::Settings; -use fabro_types::settings::{ApiAuthStrategy, GitProvider, GitSettings}; +use fabro_types::settings::{InterpString, SettingsFile}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; @@ -147,11 +146,18 @@ fn json_response(status: StatusCode, body: serde_json::Value) -> Response { (status, Json(body)).into_response() } -fn features_json(settings: &Settings) -> serde_json::Value { - let features = settings.features.clone().unwrap_or_default(); +fn features_json(settings: &SettingsFile) -> serde_json::Value { + let features = settings.features.as_ref(); + let session_sandboxes = features.and_then(|f| f.session_sandboxes).unwrap_or(false); + // Retros in v2 live under `run.execution.retros` (positive form) rather + // than the top-level features stanza. + let retros = settings + .run_execution() + .and_then(|e| e.retros) + .unwrap_or(false); json!({ - "session_sandboxes": features.session_sandboxes, - "retros": features.retros, + "session_sandboxes": session_sandboxes, + "retros": retros, }) } @@ -161,18 +167,22 @@ async fn login_github(State(state): State>) -> Response { .read() .expect("settings lock poisoned") .clone(); - let Some(client_id) = settings.client_id().map(str::to_string) else { + let Some(client_id) = settings.github_client_id_str() else { warn!("OAuth login failed: client_id not configured"); return json_response( StatusCode::CONFLICT, json!({"error": "GitHub App client_id is not configured"}), ); }; - let Some(web_url) = settings.web.as_ref().map(|web| web.url.clone()) else { - warn!("OAuth login failed: web.url not configured"); + let Some(web_url) = settings + .server_web() + .and_then(|w| w.url.as_ref()) + .map(InterpString::as_source) + else { + warn!("OAuth login failed: server.web.url not configured"); return json_response( StatusCode::CONFLICT, - json!({"error": "web.url is not configured"}), + json!({"error": "server.web.url is not configured"}), ); }; @@ -228,7 +238,7 @@ async fn callback_github( return Redirect::to("/login").into_response(); } - let Some(client_id) = settings.client_id().map(str::to_string) else { + let Some(client_id) = settings.github_client_id_str() else { error!("OAuth callback failed: client_id not configured"); return json_response( StatusCode::CONFLICT, @@ -242,10 +252,13 @@ async fn callback_github( json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}), ); }; - let web_url = settings.web.as_ref().map_or_else( - || "http://localhost:3000".to_string(), - |web| web.url.clone(), - ); + let web_url = settings + .server_web() + .and_then(|w| w.url.as_ref()) + .map_or_else( + || "http://localhost:3000".to_string(), + InterpString::as_source, + ); let http = reqwest::Client::new(); let token = match http @@ -344,9 +357,11 @@ async fn callback_github( }; let allowed_usernames = settings - .web + .server .as_ref() - .map(|web| web.auth.allowed_usernames.clone()) + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) .unwrap_or_default(); if !allowed_usernames.is_empty() && !allowed_usernames.iter().any(|user| user == &profile.login) { @@ -460,10 +475,7 @@ async fn setup_status(State(state): State>) -> Response { .read() .expect("settings lock poisoned") .clone(); - let configured = settings - .git - .as_ref() - .is_some_and(|git| git.client_id.is_some()); + let configured = settings.github_client_id_str().is_some(); Json(SetupStatusResponse { configured }).into_response() } @@ -547,30 +559,26 @@ async fn setup_register( let settings_path = state.config_path.clone(); - let mut settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); - let mut git = settings.git.clone().unwrap_or_default(); - git.provider = GitProvider::Github; - git.app_id = Some(data.id.to_string()); - git.client_id = Some(data.client_id.clone()); - git.slug = Some(data.slug.clone()); - settings.git = Some(git.clone()); - if let Some(ref origin) = origin { - let web = settings.web.get_or_insert_default(); - web.url.clone_from(origin); - } - + // Edit the settings file in place via `toml_edit::DocumentMut`, which + // preserves existing comments, whitespace, and key ordering. The value- + // tree parser (`toml::Value`) would strip all of that on round-trip. if let Some(parent) = settings_path.parent() { - let _ = std::fs::create_dir_all(parent); + if let Err(err) = std::fs::create_dir_all(parent) { + error!(error = %err, path = %parent.display(), "Setup register failed: could not create settings parent directory"); + return json_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": format!("Failed to create settings directory: {err}")}), + ); + } } let existing = std::fs::read_to_string(&settings_path).unwrap_or_default(); - let mut doc: toml::Value = if existing.is_empty() { - toml::Value::Table(toml::Table::default()) + let mut doc: toml_edit::DocumentMut = if existing.is_empty() { + toml_edit::DocumentMut::new() } else { - match toml::from_str(&existing).context("failed to parse existing settings config") { + match existing + .parse::() + .context("failed to parse existing settings config") + { Ok(doc) => doc, Err(err) => { error!(error = %err, path = %settings_path.display(), "Setup register failed: could not parse settings config"); @@ -581,17 +589,14 @@ async fn setup_register( } } }; - if let Err(err) = merge_settings_keys(&mut doc, &settings, &git, origin.as_deref()) { + if let Err(err) = merge_settings_keys(&mut doc, &data, origin.as_deref()) { error!(error = %err, "Setup register failed: could not merge settings"); return json_response( StatusCode::INTERNAL_SERVER_ERROR, json!({"error": format!("Failed to update settings config: {err}")}), ); } - if let Err(err) = std::fs::write( - &settings_path, - toml::to_string_pretty(&doc).unwrap_or_default(), - ) { + if let Err(err) = std::fs::write(&settings_path, doc.to_string()) { error!(error = %err, path = %settings_path.display(), "Setup register failed: could not write settings config"); return json_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -622,176 +627,232 @@ async fn setup_register( } } - { - let mut shared = state.settings.write().expect("settings lock poisoned"); - *shared = settings; + // Re-parse the freshly-written settings file and swap it into the + // in-memory state so subsequent OAuth requests see the new GitHub + // App credentials without a server restart. + match fabro_config::ConfigLayer::load(&settings_path) { + Ok(reloaded) => { + let mut shared = state.settings.write().expect("settings lock poisoned"); + *shared = reloaded.into(); + } + Err(err) => { + error!(error = %err, path = %settings_path.display(), "Setup register failed: could not reload written settings config"); + return json_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": format!("Failed to reload settings config after write: {err}")}), + ); + } } info!(slug = %data.slug, app_id = %data.id, "GitHub App registered successfully"); Json(json!({"ok": true})).into_response() } -fn root_table_mut(doc: &mut toml::Value) -> anyhow::Result<&mut toml::Table> { - doc.as_table_mut() - .ok_or_else(|| anyhow!("settings config root is not a table")) +/// Walk dotted `path` into `doc`, creating missing intermediate tables, +/// and return a mutable reference to the terminal table. +/// +/// Uses `toml_edit`'s [`toml_edit::Entry::or_insert`] so existing tables +/// keep their comments, ordering, and any sibling keys untouched. +fn ensure_nested_table<'a>( + doc: &'a mut toml_edit::DocumentMut, + path: &[&str], +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut current: &mut toml_edit::Table = doc.as_table_mut(); + for segment in path { + let next = current + .entry(segment) + .or_insert(toml_edit::Item::Table(toml_edit::Table::new())); + current = next + .as_table_mut() + .ok_or_else(|| anyhow!("settings config [{segment}] is not a table"))?; + } + Ok(current) } -fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> anyhow::Result<&'a mut toml::Table> { - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::Table::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("settings config [{key}] is not a table")) +/// Set a scalar value on `table[key]`, preserving any key-level decor +/// (leading comments, blank lines) that was attached to the existing entry. +/// +/// `toml_edit`'s default `Table::insert` replaces the entry wholesale and +/// drops its prefix decoration, which would strip a top-of-file comment +/// attached to a key we're updating. Copying the decor forward keeps the +/// user's formatting intact. +fn set_preserving_decor(table: &mut toml_edit::Table, key: &str, value: toml_edit::Item) { + let preserved_decor = table.key(key).map(|existing| existing.leaf_decor().clone()); + table.insert(key, value); + if let (Some(decor), Some(mut updated)) = (preserved_decor, table.key_mut(key)) { + *updated.leaf_decor_mut() = decor; + } } fn merge_settings_keys( - doc: &mut toml::Value, - settings: &Settings, - git: &GitSettings, + doc: &mut toml_edit::DocumentMut, + data: &GitHubManifestConversion, origin: Option<&str>, ) -> anyhow::Result<()> { - let web_url = origin - .map(str::to_string) - .or_else(|| settings.web.as_ref().map(|web| web.url.clone())) - .unwrap_or_else(|| "http://localhost:3000".to_string()); - let allowed = settings - .web - .as_ref() - .map(|web| web.auth.allowed_usernames.clone()) - .unwrap_or_default(); - let api = settings.api.clone().unwrap_or_default(); + let web_url = origin.map_or_else(|| "http://localhost:3000".to_string(), str::to_string); - let root = root_table_mut(doc)?; - let web = ensure_table(root, "web")?; - web.insert("url".to_string(), toml::Value::String(web_url.clone())); - let auth = ensure_table(web, "auth")?; - auth.insert( - "provider".to_string(), - toml::Value::String("github".to_string()), - ); - auth.insert( - "allowed_usernames".to_string(), - toml::Value::Array(allowed.into_iter().map(toml::Value::String).collect()), - ); + // Make sure the freshly-written file is a valid v2 file. `_version` is + // always `1` at the moment, so skip the write entirely if it's already + // there -- otherwise we'd trample any top-of-file comment attached to + // the key. + let root = doc.as_table_mut(); + if !root.contains_key("_version") { + root.insert("_version", toml_edit::value(1_i64)); + } - let base_url = format!("{web_url}/api/v1"); - let api_table = ensure_table(root, "api")?; - api_table.insert("base_url".to_string(), toml::Value::String(base_url)); - api_table.insert( - "authentication_strategies".to_string(), - toml::Value::Array( - api.authentication_strategies - .iter() - .map(|strategy| match strategy { - ApiAuthStrategy::Jwt => "jwt", - ApiAuthStrategy::Mtls => "mtls", - }) - .map(|value| toml::Value::String(value.to_string())) - .collect(), - ), - ); + let web = ensure_nested_table(doc, &["server", "web"])?; + set_preserving_decor(web, "enabled", toml_edit::value(true)); + set_preserving_decor(web, "url", toml_edit::value(web_url)); - let git_table = ensure_table(root, "git")?; - git_table.insert( - "provider".to_string(), - toml::Value::String("github".to_string()), - ); - git_table.insert( - "app_id".to_string(), - toml::Value::String(git.app_id.clone().unwrap_or_default()), - ); - git_table.insert( - "client_id".to_string(), - toml::Value::String(git.client_id.clone().unwrap_or_default()), - ); - git_table.insert( - "slug".to_string(), - toml::Value::String(git.slug.clone().unwrap_or_default()), + // Ensure the auth subtrees exist so a freshly-registered GitHub App + // resolves `[server.auth.web]` / `[server.auth.api.jwt]` strategies on + // the next startup without a second manual edit. + ensure_nested_table(doc, &["server", "auth", "web"])?; + ensure_nested_table(doc, &["server", "auth", "api", "jwt"])?; + + let github = ensure_nested_table(doc, &["server", "integrations", "github"])?; + set_preserving_decor(github, "app_id", toml_edit::value(data.id.to_string())); + set_preserving_decor( + github, + "client_id", + toml_edit::value(data.client_id.clone()), ); + set_preserving_decor(github, "slug", toml_edit::value(data.slug.clone())); Ok(()) } #[cfg(test)] mod tests { - use super::merge_settings_keys; - use fabro_types::Settings; + use super::{GitHubManifestConversion, merge_settings_keys}; + + fn sample_conversion() -> GitHubManifestConversion { + GitHubManifestConversion { + id: 123, + slug: "fabro".to_string(), + client_id: "abc".to_string(), + client_secret: "shh".to_string(), + pem: String::new(), + webhook_secret: None, + } + } + + fn parse_doc(source: &str) -> toml_edit::DocumentMut { + source + .parse::() + .expect("fixture should parse as TOML") + } #[test] - fn merge_settings_keys_preserves_unrelated_git_nested_keys() { - let mut doc: toml::Value = toml::from_str( - r#" -[git] -provider = "github" + fn merge_settings_keys_writes_v2_server_integrations_github() { + let mut doc = parse_doc("_version = 1\n"); + merge_settings_keys(&mut doc, &sample_conversion(), Some("https://example.test")).unwrap(); -[git.author] -name = "fabro" -email = "fabro@example.com" + let github = doc["server"]["integrations"]["github"] + .as_table() + .expect("server.integrations.github should exist"); + assert_eq!(github["app_id"].as_str(), Some("123")); + assert_eq!(github["slug"].as_str(), Some("fabro")); + assert_eq!(github["client_id"].as_str(), Some("abc")); -[git.webhooks] -strategy = "tailscale_funnel" -"#, - ) - .unwrap(); + let web = doc["server"]["web"] + .as_table() + .expect("server.web should exist"); + assert_eq!(web["url"].as_str(), Some("https://example.test")); + assert_eq!(web["enabled"].as_bool(), Some(true)); - let mut settings = Settings::default(); - settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()]; - settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github; - settings.git.get_or_insert_default().app_id = Some("123".to_string()); - settings.git.get_or_insert_default().client_id = Some("abc".to_string()); - settings.git.get_or_insert_default().slug = Some("fabro".to_string()); - - merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap(), None).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(); + // Re-parse the emitted document to prove it round-trips into a + // valid v2 `SettingsFile`. + let emitted = doc.to_string(); + let file = fabro_config::ConfigLayer::parse(&emitted) + .expect("merged output should parse as a v2 SettingsFile"); + let server = file + .as_v2() + .server + .as_ref() + .expect("[server] should be present"); + let integrations = server + .integrations + .as_ref() + .expect("[server.integrations] should be present"); + let github = integrations + .github + .as_ref() + .expect("[server.integrations.github] should be present"); assert_eq!( - author.get("name").and_then(toml::Value::as_str), - Some("fabro") - ); - let webhooks = git.get("webhooks").and_then(toml::Value::as_table).unwrap(); - assert_eq!( - webhooks.get("strategy").and_then(toml::Value::as_str), - Some("tailscale_funnel") + github.app_id.as_ref().map(|s| s.as_source()), + Some("123".to_string()) ); } #[test] - fn merge_settings_keys_preserves_unrelated_top_level_sections() { - let mut doc: toml::Value = toml::from_str( - r#" -[exec] -provider = "anthropic" + fn merge_settings_keys_preserves_comments_and_unrelated_keys() { + let existing = r##"# Top-of-file comment explaining the settings layout. +_version = 1 -[server] -target = "https://fabro.example.com/api/v1" -"#, +# Storage root comment — should survive the edit. +[server.storage] +root = "/srv/fabro-data" + +# A pre-existing integration that is NOT github. +[server.integrations.slack] +default_channel = "#ops" + +[run.model] +provider = "anthropic" +name = "claude-sonnet" +"##; + let mut doc = parse_doc(existing); + merge_settings_keys( + &mut doc, + &sample_conversion(), + Some("https://fabro.example"), ) .unwrap(); - let mut settings = Settings::default(); - settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()]; - settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github; - settings.git.get_or_insert_default().app_id = Some("123".to_string()); - settings.git.get_or_insert_default().client_id = Some("abc".to_string()); - settings.git.get_or_insert_default().slug = Some("fabro".to_string()); + let emitted = doc.to_string(); - merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap(), None).unwrap(); + // Comments must survive the round-trip. + assert!( + emitted.contains("# Top-of-file comment explaining the settings layout."), + "top-of-file comment was stripped:\n{emitted}" + ); + assert!( + emitted.contains("# Storage root comment — should survive the edit."), + "inline table comment was stripped:\n{emitted}" + ); + assert!( + emitted.contains("# A pre-existing integration that is NOT github."), + "sibling-table comment was stripped:\n{emitted}" + ); - assert_eq!( - doc.get("exec") - .and_then(toml::Value::as_table) - .and_then(|exec| exec.get("provider")) - .and_then(toml::Value::as_str), - Some("anthropic") + // Unrelated keys must still be intact. + assert!( + emitted.contains(r#"root = "/srv/fabro-data""#), + "server.storage.root was lost:\n{emitted}" ); - assert_eq!( - doc.get("server") - .and_then(toml::Value::as_table) - .and_then(|server| server.get("target")) - .and_then(toml::Value::as_str), - Some("https://fabro.example.com/api/v1") + assert!( + emitted.contains(r##"default_channel = "#ops""##), + "server.integrations.slack.default_channel was lost:\n{emitted}" ); + assert!( + emitted.contains(r#"provider = "anthropic""#), + "run.model.provider was lost:\n{emitted}" + ); + + // And the new keys must be present. + assert!( + emitted.contains(r#"app_id = "123""#), + "server.integrations.github.app_id missing:\n{emitted}" + ); + assert!( + emitted.contains(r#"url = "https://fabro.example""#), + "server.web.url missing:\n{emitted}" + ); + + // Finally, the whole thing must still parse as a valid v2 + // SettingsFile. + fabro_config::ConfigLayer::parse(&emitted) + .expect("merged output should still parse as v2 after the edit"); } } diff --git a/lib/crates/fabro-server/tests/it/api/mtls.rs b/lib/crates/fabro-server/tests/it/api/mtls.rs index 9ae4ade9f..b43f96e6f 100644 --- a/lib/crates/fabro-server/tests/it/api/mtls.rs +++ b/lib/crates/fabro-server/tests/it/api/mtls.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use fabro_server::jwt_auth::{AuthMode, AuthStrategy}; use fabro_server::server::{build_router, create_app_state}; -use fabro_server::server_config::TlsSettings; use fabro_server::tls::{ClientAuth, build_rustls_config}; +use fabro_server::tls_config::TlsSettings; use tokio::net::TcpListener; fn fixture_path(name: &str) -> PathBuf { diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 6131ffaca..5b8276b06 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -1,11 +1,12 @@ use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, StatusCode}; +use fabro_config::ConfigLayer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ RouterOptions, build_router, build_router_with_options, create_app_state, create_app_state_with_options, }; -use fabro_types::Settings; +use fabro_types::settings::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; @@ -120,13 +121,16 @@ async fn web_enabled_serves_web_only_routes() { #[tokio::test] async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -[web] +_version = 1 + +[server.web] enabled = false "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router_with_options( create_app_state_with_options(settings, 5), AuthMode::Disabled, @@ -175,13 +179,16 @@ enabled = false #[tokio::test] async fn web_disabled_ignores_demo_header_dispatch() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -[web] +_version = 1 + +[server.web] enabled = false "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router_with_options( create_app_state_with_options(settings, 5), AuthMode::Disabled, diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index a204a2908..fe7e82ee5 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -1,25 +1,34 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use fabro_config::ConfigLayer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{build_router, create_app_state_with_options}; -use fabro_types::Settings; +use fabro_types::settings::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; #[tokio::test] async fn retrieve_server_settings_returns_runtime_settings() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -storage_dir = "/srv/fabro" -max_concurrent_runs = 9 -verbose = true +_version = 1 -[vars] +[server.storage] +root = "/srv/fabro" + +[server.scheduler] +max_concurrent_runs = 9 + +[cli.output] +verbosity = "verbose" + +[run.inputs] server_only = "1" "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router( create_app_state_with_options(settings, 5), AuthMode::Disabled, @@ -34,8 +43,10 @@ server_only = "1" assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - assert_eq!(body["storage_dir"], "/srv/fabro"); - assert_eq!(body["max_concurrent_runs"], 9); - assert_eq!(body["verbose"], true); - assert_eq!(body["vars"]["server_only"], "1"); + // `/api/v1/settings` emits the v2 SettingsFile shape directly now. + // Stage 6.6 will replace this with an explicit allow-list DTO. + assert_eq!(body["server"]["storage"]["root"], "/srv/fabro"); + assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); + assert_eq!(body["cli"]["output"]["verbosity"], "verbose"); + assert_eq!(body["run"]["inputs"]["server_only"], "1"); } diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index f63530a60..52191a688 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -3,8 +3,13 @@ use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_config::Storage; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; +use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; use http_body_util::BodyExt; +use std::path::PathBuf; use tempfile::tempdir; use tokio::time::timeout; use tower::ServiceExt; @@ -14,12 +19,18 @@ use crate::helpers::{ test_app_with_scheduler, test_settings, wait_for_run_status, }; -fn temp_storage_settings() -> (tempfile::TempDir, Settings) { +fn temp_storage_settings() -> (tempfile::TempDir, SettingsFile, PathBuf) { let temp = tempdir().expect("tempdir should create"); let mut settings = test_settings(); - settings.dry_run = Some(true); - settings.storage_dir = Some(temp.path().join("storage")); - (temp, settings) + let storage_dir = temp.path().join("storage"); + 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); + let server = settings.server.get_or_insert_with(ServerLayer::default); + server.storage = Some(ServerStorageLayer { + root: Some(InterpString::parse(&storage_dir.to_string_lossy())), + }); + (temp, settings, storage_dir) } async fn create_run(app: &axum::Router, manifest: serde_json::Value) -> String { @@ -46,8 +57,7 @@ async fn start_run(app: &axum::Router, run_id: &str) { #[tokio::test] async fn get_system_info_returns_runtime_fields() { - let (_temp, settings) = temp_storage_settings(); - let expected_storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, expected_storage_dir) = temp_storage_settings(); let app = fabro_server::server::build_router( test_app_state_with_options(settings, 5), fabro_server::jwt_auth::AuthMode::Disabled, @@ -75,8 +85,7 @@ async fn get_system_info_returns_runtime_fields() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_system_disk_usage_returns_summary_and_verbose_rows() { - let (_temp, settings) = temp_storage_settings(); - let storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; @@ -108,8 +117,7 @@ async fn get_system_disk_usage_returns_summary_and_verbose_rows() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn prune_runs_supports_dry_run_and_deletion() { - let (_temp, settings) = temp_storage_settings(); - let storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; @@ -154,7 +162,7 @@ async fn prune_runs_supports_dry_run_and_deletion() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attach_events_streams_only_matching_run_ids() { - let (_temp, settings) = temp_storage_settings(); + let (_temp, settings, _storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_one = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 53105fb17..4487235a0 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -8,8 +8,10 @@ use fabro_server::server::{ AppState, build_router, create_app_state, create_app_state_with_settings_and_registry_factory, spawn_scheduler, }; -use fabro_types::Settings; -use fabro_types::settings::{LocalSandboxSettings, SandboxSettings, WorktreeMode}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{ + LocalSandboxLayer, RunExecutionLayer, RunLayer, RunMode, RunSandboxLayer, WorktreeMode, +}; use tokio::time::sleep; use tower::ServiceExt; @@ -28,7 +30,7 @@ pub(crate) fn test_app_state() -> Arc { } pub(crate) fn test_app_state_with_options( - settings: Settings, + settings: SettingsFile, max_concurrent_runs: usize, ) -> Arc { let _ = max_concurrent_runs; @@ -37,23 +39,27 @@ pub(crate) fn test_app_state_with_options( }) } -pub(crate) fn test_settings() -> Settings { - Settings { - sandbox: Some(SandboxSettings { - local: Some(LocalSandboxSettings { - worktree_mode: WorktreeMode::Never, +pub(crate) fn test_settings() -> SettingsFile { + SettingsFile { + run: Some(RunLayer { + sandbox: Some(RunSandboxLayer { + local: Some(LocalSandboxLayer { + worktree_mode: Some(WorktreeMode::Never), + }), + ..RunSandboxLayer::default() }), - ..Default::default() + ..RunLayer::default() }), - ..Default::default() + ..SettingsFile::default() } } -pub(crate) fn dry_run_settings() -> Settings { - Settings { - dry_run: Some(true), - ..test_settings() - } +pub(crate) fn dry_run_settings() -> SettingsFile { + let mut settings = test_settings(); + 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); + settings } pub(crate) fn dry_run_app() -> axum::Router { diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 33fc5e158..da24d4b0b 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -1,4 +1,4 @@ -//! Conformance tests: spec ↔ router ↔ Rust struct consistency. +//! Conformance tests: spec ↔ router consistency. #![allow( clippy::absolute_paths, @@ -8,21 +8,11 @@ )] use super::helpers::test_app_state; -use std::collections::BTreeSet; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; -use fabro_config::run::*; -use fabro_config::sandbox::SandboxSettings; -use fabro_hooks::*; -use fabro_sandbox::daytona::*; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; -use fabro_server::server_config::*; -use fabro_types::settings::{ - ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ProjectSettings, - ServerSettings as UserServerSettings, -}; use tower::ServiceExt; fn load_spec() -> openapiv3::OpenAPI { @@ -105,332 +95,10 @@ async fn all_spec_routes_are_routable() { assert!(checked > 0, "No routes were checked — is the spec empty?"); } -// ── ServerConfig ↔ OpenAPI schema drift detection ────────────────────── - -/// Load the spec as serde_json::Value for schema introspection. -fn load_spec_json() -> serde_json::Value { - let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join("docs/api-reference/fabro-api.yaml"); - let text = std::fs::read_to_string(&spec_path).expect("read spec"); - serde_yaml::from_str(&text).expect("parse spec") -} - -/// Follow a `$ref` pointer, or return the value unchanged. -fn resolve_ref<'a>( - value: &'a serde_json::Value, - root: &'a serde_json::Value, -) -> &'a serde_json::Value { - match value.get("$ref").and_then(|v| v.as_str()) { - Some(ref_str) => { - let mut cur = root; - for seg in ref_str.trim_start_matches("#/").split('/') { - cur = &cur[seg]; - } - cur - } - None => value, - } -} - -/// Collect property names from an OpenAPI schema object. -fn spec_keys(schema: &serde_json::Value) -> BTreeSet { - schema - .get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default() -} - -/// Recursively compare serialized JSON keys against OpenAPI schema properties. -fn compare_schema( - path: &str, - json: &serde_json::Value, - schema: &serde_json::Value, - root: &serde_json::Value, - errors: &mut Vec, -) { - let obj = match json.as_object() { - Some(o) => o, - None => return, - }; - - // Skip pure-map schemas (additionalProperties without properties). - if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() { - return; - } - - let json_keys: BTreeSet = obj.keys().cloned().collect(); - let schema_keys = spec_keys(schema); - - for key in json_keys.difference(&schema_keys) { - errors.push(format!( - "{path}.{key}: in Rust but missing from OpenAPI spec" - )); - } - for key in schema_keys.difference(&json_keys) { - errors.push(format!( - "{path}.{key}: in OpenAPI spec but missing from Rust" - )); - } - - let properties = match schema.get("properties").and_then(|p| p.as_object()) { - Some(p) => p, - None => return, - }; - - for key in json_keys.intersection(&schema_keys) { - let json_val = &obj[key]; - let prop_schema = resolve_ref(&properties[key], root); - - // Skip maps and union types. - if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() { - continue; - } - - match json_val { - serde_json::Value::Object(_) => { - compare_schema( - &format!("{path}.{key}"), - json_val, - prop_schema, - root, - errors, - ); - } - serde_json::Value::Array(arr) => { - // Union keys across all array elements. - let union: BTreeSet = arr - .iter() - .filter_map(|e| e.as_object()) - .flat_map(|o| o.keys().cloned()) - .collect(); - if union.is_empty() { - continue; - } - let items = match prop_schema.get("items") { - Some(i) => resolve_ref(i, root), - None => continue, - }; - let synthetic = serde_json::Value::Object( - union - .into_iter() - .map(|k| (k, serde_json::Value::Null)) - .collect(), - ); - compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors); - } - _ => {} - } - } -} - -/// Build a Settings with every Option set to Some so all keys appear -/// in the serialized JSON. -fn fully_populated_server_config() -> Settings { - Settings { - version: Some(1), - goal: Some("default goal".into()), - goal_file: Some("/tmp/goal.txt".into()), - graph: Some("workflow.fabro".into()), - labels: std::collections::HashMap::from([("scope".into(), "server".into())]), - server: Some(UserServerSettings { - target: Some("https://server.example.com".into()), - tls: Some(ClientTlsSettings { - cert: "client-cert.pem".into(), - key: "client-key.pem".into(), - ca: "ca.pem".into(), - }), - }), - exec: Some(ExecSettings { - provider: Some("openai".into()), - model: Some("gpt-5.4".into()), - permissions: Some(PermissionLevel::ReadWrite), - output_format: Some(OutputFormat::Json), - }), - prevent_idle_sleep: Some(true), - verbose: Some(true), - upgrade_check: Some(false), - dry_run: Some(true), - auto_approve: Some(true), - no_retro: Some(true), - storage_dir: Some("/data".into()), - max_concurrent_runs: Some(10), - web: Some(WebSettings { - enabled: true, - url: "https://example.com".into(), - auth: AuthSettings { - provider: AuthProvider::Github, - allowed_usernames: vec!["user".into()], - }, - }), - api: Some(ApiSettings { - base_url: "https://api.example.com".into(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - tls: Some(TlsSettings { - cert: "c".into(), - key: "k".into(), - ca: "ca".into(), - }), - }), - git: Some(GitSettings { - provider: GitProvider::Github, - app_id: Some("123".into()), - client_id: Some("456".into()), - slug: Some("fabro".into()), - author: GitAuthorSettings { - name: Some("bot".into()), - email: Some("bot@x".into()), - }, - webhooks: Some(WebhookSettings { - strategy: WebhookStrategy::TailscaleFunnel, - }), - }), - features: Some(FeaturesSettings { - session_sandboxes: true, - retros: false, - }), - log: Some(LogSettings { - level: Some("debug".into()), - }), - work_dir: Some("/work".into()), - llm: Some(LlmSettings { - model: Some("m".into()), - provider: Some("p".into()), - fallbacks: Some(Default::default()), - }), - setup: Some(SetupSettings { - commands: vec!["echo hi".into()], - timeout_ms: Some(5000), - }), - sandbox: Some(SandboxSettings { - provider: Some("daytona".into()), - preserve: Some(true), - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - auto_stop_interval: Some(60), - labels: Some(Default::default()), - snapshot: Some(DaytonaSnapshotConfig { - name: "snap".into(), - cpu: Some(2), - memory: Some(4), - disk: Some(10), - dockerfile: Some(DockerfileSource::Inline("FROM x".into())), - }), - network: Some(DaytonaNetwork::Block), - skip_clone: false, - }), - env: Some(Default::default()), - }), - vars: Some(Default::default()), - checkpoint: CheckpointSettings { - exclude_globs: vec!["**/node_modules/**".into()], - }, - pull_request: Some(PullRequestSettings { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, - }), - artifacts: Some(ArtifactsSettings { - include: vec!["test-results/**".into()], - }), - // One hook per HookType variant so the key union covers all fields. - hooks: vec![ - HookDefinition { - name: Some("cmd".into()), - event: HookEvent::RunStart, - command: Some("echo".into()), - hook_type: None, - matcher: Some("*".into()), - blocking: Some(true), - timeout_ms: Some(5000), - sandbox: Some(true), - }, - HookDefinition { - name: Some("http".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Http { - url: "http://x".into(), - headers: Some(Default::default()), - allowed_env_vars: vec!["X".into()], - tls: TlsMode::Verify, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("prompt".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Prompt { - prompt: "hi".into(), - model: Some("m".into()), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("agent".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Agent { - prompt: "hi".into(), - model: Some("m".into()), - max_tool_rounds: Some(5), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - ], - mcp_servers: std::collections::HashMap::from([( - "test".into(), - fabro_config::mcp::McpServerEntry { - transport: fabro_config::mcp::McpTransport::Stdio { - command: vec!["echo".into()], - env: Default::default(), - }, - startup_timeout_secs: fabro_config::mcp::default_startup_timeout_secs(), - tool_timeout_secs: fabro_config::mcp::default_tool_timeout_secs(), - }, - )]), - github: Some(GitHubSettings { - permissions: std::collections::HashMap::from([("contents".into(), "read".into())]), - }), - fabro: Some(ProjectSettings { - root: "fabro".into(), - }), - ..Default::default() - } -} - -#[test] -fn server_settings_keys_match_openapi_spec() { - let settings = fully_populated_server_config(); - let json = serde_json::to_value(&settings).expect("serialize ServerSettings"); - let spec = load_spec_json(); - let schema = &spec["components"]["schemas"]["ServerSettings"]; - - let mut errors = Vec::new(); - compare_schema("ServerSettings", &json, schema, &spec, &mut errors); - - if !errors.is_empty() { - panic!( - "ServerSettings ↔ OpenAPI schema drift:\n {}", - errors.join("\n ") - ); - } -} +// Note: the earlier `server_settings_keys_match_openapi_spec` drift check +// was deleted in Stage 6.3b alongside the legacy flat `fabro_types::Settings` +// struct that it instantiated. The v2 `/api/v1/settings` and +// `/api/v1/runs/:id/settings` endpoints now return the freely-shaped +// `SettingsFile` tree which the OpenAPI spec declares as +// `type: object, additionalProperties: true`, so there is nothing to diff +// at the property-key level. diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index f7e39e556..df4f0340e 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -599,9 +599,10 @@ mod tests { use super::{NodeState, RunProjection}; use crate::{EventEnvelope, EventPayload, StageId}; use fabro_types::run_event::{InterviewCompletedProps, InterviewOption, InterviewStartedProps}; + use fabro_types::settings::SettingsFile; use fabro_types::{ Checkpoint, EventBody, InterviewQuestionType, RunBlobId, RunControlAction, RunEvent, - Settings, fixtures, + fixtures, }; fn test_event(seq: u32, body: EventBody, node_id: Option<&str>) -> EventEnvelope { @@ -813,7 +814,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": Settings::default(), + "settings": SettingsFile::default(), "graph": { "name": "test", "nodes": {}, diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 93a838ef7..813f3b05d 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -233,9 +233,8 @@ mod tests { use super::*; use chrono::{DateTime, Utc}; - use fabro_types::{ - AttrValue, Graph, RunControlAction, RunRecord, RunStatus, Settings, StatusReason, - }; + use fabro_types::settings::SettingsFile; + use fabro_types::{AttrValue, Graph, RunControlAction, RunRecord, RunStatus, StatusReason}; use futures::TryStreamExt; use object_store::memory::InMemory; use object_store::path::Path; @@ -280,7 +279,7 @@ mod tests { ); RunRecord { run_id: test_run_id(label), - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from(format!("/tmp/{label}")), diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 35b485f01..aceafbc85 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -91,7 +91,10 @@ pub struct EventEnvelope { mod tests { use chrono::{TimeZone, Utc}; - use fabro_types::{EventBody, RunEvent, StageId, fixtures, run_event::RunCompletedProps}; + use fabro_types::{ + ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, fixtures, + run_event::RunCompletedProps, + }; use super::{EventEnvelope, EventPayload}; @@ -133,4 +136,57 @@ mod tests { let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); assert_eq!(parsed, envelope); } + + #[test] + fn wire_event_envelope_round_trips_with_all_envelope_fields() { + let group = StageId::new("review", 2); + let branch = ParallelBranchId::new(group.clone(), 3); + let event = RunEvent { + id: "evt_2".to_string(), + ts: Utc.with_ymd_and_hms(2026, 4, 9, 13, 0, 0).unwrap(), + run_id: fixtures::RUN_1, + node_id: Some("review".to_string()), + node_label: Some("Review".to_string()), + stage_id: Some(StageId::new("review", 2)), + parallel_group_id: Some(group), + parallel_branch_id: Some(branch), + session_id: Some("ses_42".to_string()), + parent_session_id: Some("ses_root".to_string()), + tool_call_id: Some("tool_call_xyz".to_string()), + actor: Some(ActorRef::agent( + Some("ses_42".to_string()), + Some("claude-sonnet".to_string()), + )), + body: EventBody::RunCompleted(RunCompletedProps { + duration_ms: 100, + artifact_count: 1, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + }; + let payload = EventPayload::new(event.to_value().unwrap(), &fixtures::RUN_1).unwrap(); + let envelope = EventEnvelope { seq: 99, payload }; + + let wire = serde_json::to_value(&envelope).unwrap(); + assert_eq!(wire["seq"], 99); + assert_eq!(wire["id"], "evt_2"); + assert_eq!(wire["stage_id"], "review@2"); + assert_eq!(wire["parallel_group_id"], "review@2"); + assert_eq!(wire["parallel_branch_id"], "review@2:3"); + assert_eq!(wire["session_id"], "ses_42"); + assert_eq!(wire["parent_session_id"], "ses_root"); + assert_eq!(wire["tool_call_id"], "tool_call_xyz"); + assert_eq!(wire["actor"]["kind"], "agent"); + assert_eq!(wire["actor"]["id"], "ses_42"); + assert_eq!(wire["actor"]["display"], "claude-sonnet"); + assert_eq!(wire["event"], "run.completed"); + assert!(wire.get("payload").is_none(), "wire shape must be flat"); + + let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); + assert_eq!(parsed, envelope); + } } diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index b058cc5bf..b7af329d6 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -335,7 +335,7 @@ fn write_marker(root: &Path) { fn managed_storage_settings(storage_dir: &Path, rest: &str) -> String { format!( - "{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\n{rest}", + "{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}", storage_dir.display() ) } @@ -349,24 +349,24 @@ fn strip_managed_storage_settings(contents: &str) -> &str { .strip_prefix(MANAGED_STORAGE_MARKER) .and_then(|rest| rest.strip_prefix('\n')) .unwrap_or(""); - let (first_line, mut rest) = after_marker.split_once('\n').unwrap_or((after_marker, "")); - if !first_line.starts_with("storage_dir = ") { - return after_marker; - } - if let Some((maybe_target, tail)) = rest.split_once('\n') { - if maybe_target.starts_with("server.target = ") { - rest = tail; - } - } - rest + after_marker } fn settings_storage_dir(settings_path: &Path) -> Option { let content = std::fs::read_to_string(settings_path).ok()?; - let value = toml::from_str::(strip_managed_storage_settings(&content)).ok()?; + // Settings files that fabro-test injected with its managed marker are + // not treated as user-explicit storage overrides — the override tracks + // ONLY what the test itself asked for. + if content.starts_with(MANAGED_STORAGE_MARKER) { + return None; + } + let value = toml::from_str::(&content).ok()?; value - .get("storage_dir") - .or_else(|| value.get("data_dir")) + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("storage")) + .and_then(toml::Value::as_table) + .and_then(|storage| storage.get("root")) .and_then(toml::Value::as_str) .map(PathBuf::from) } @@ -379,7 +379,10 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) { ensure_parent_dir(path); std::fs::write( path, - format!("storage_dir = \"{}\"\n{rest}", storage_dir.display()), + format!( + "_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}", + storage_dir.display() + ), ) .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); } @@ -407,36 +410,45 @@ fn write_settings_table(path: &Path, table: &TomlMap) { fn server_target_from_table(table: &TomlMap) -> Option { table - .get("server") + .get("cli") .and_then(TomlValue::as_table) - .and_then(|server| server.get("target")) + .and_then(|cli| cli.get("target")) + .and_then(TomlValue::as_table) + .and_then(|target| target.get("path").or_else(|| target.get("url"))) .and_then(TomlValue::as_str) .map(ToOwned::to_owned) } fn set_server_target(table: &mut TomlMap, socket_path: &Path) { - let server_entry = table - .entry("server".to_string()) + let cli_entry = table + .entry("cli".to_string()) .or_insert_with(|| TomlValue::Table(TomlMap::new())); - let Some(server_table) = server_entry.as_table_mut() else { - panic!("expected [server] to be a TOML table"); + let Some(cli_table) = cli_entry.as_table_mut() else { + panic!("expected [cli] to be a TOML table"); }; - server_table.insert( - "target".to_string(), + let target_entry = cli_table + .entry("target".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(target_table) = target_entry.as_table_mut() else { + panic!("expected [cli.target] to be a TOML table"); + }; + target_table.insert("type".to_string(), TomlValue::String("unix".to_string())); + target_table.insert( + "path".to_string(), TomlValue::String(socket_path.display().to_string()), ); } fn clear_server_target(table: &mut TomlMap) { - let Some(server_entry) = table.get_mut("server") else { + let Some(cli_entry) = table.get_mut("cli") else { return; }; - let Some(server_table) = server_entry.as_table_mut() else { + let Some(cli_table) = cli_entry.as_table_mut() else { return; }; - server_table.remove("target"); - if server_table.is_empty() { - table.remove("server"); + cli_table.remove("target"); + if cli_table.is_empty() { + table.remove("cli"); } } @@ -451,8 +463,8 @@ fn sync_home_settings( Ok(contents) => { let had_managed_storage = contents.starts_with(MANAGED_STORAGE_MARKER); let table = parse_settings_table(&contents, settings_path); - let had_explicit_storage = !had_managed_storage - && (table.contains_key("storage_dir") || table.contains_key("data_dir")); + let had_explicit_storage = + !had_managed_storage && has_explicit_storage_root(&table); let had_explicit_target = server_target_from_table(&table).is_some(); (table, had_explicit_storage, had_explicit_target) } @@ -462,12 +474,12 @@ fn sync_home_settings( Err(err) => panic!("failed to read {}: {err}", settings_path.display()), }; + table + .entry("_version".to_string()) + .or_insert(TomlValue::Integer(1)); + if !had_explicit_storage { - table.insert( - "storage_dir".to_string(), - TomlValue::String(storage_dir.display().to_string()), - ); - table.remove("data_dir"); + set_server_storage_root(&mut table, storage_dir); } if force_server_target || (!had_explicit_storage && !had_explicit_target) { @@ -478,21 +490,23 @@ fn sync_home_settings( if !had_explicit_storage { let mut rest = table.clone(); - rest.remove("storage_dir"); + clear_server_storage(&mut rest); + rest.remove("_version"); let managed_target = !had_explicit_target && !force_server_target; if managed_target { clear_server_target(&mut rest); } - let rest = toml::to_string(&rest) + let rest_toml = toml::to_string(&rest) .unwrap_or_else(|err| panic!("failed to serialize {}: {err}", settings_path.display())); - let mut contents = managed_storage_settings(storage_dir, &rest); - if managed_target { - contents = format!( - "{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\nserver.target = \"{}\"\n{rest}", + let contents = if managed_target { + format!( + "{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n\n{rest_toml}", storage_dir.display(), socket_path.display() - ); - } + ) + } else { + managed_storage_settings(storage_dir, &rest_toml) + }; ensure_parent_dir(settings_path); std::fs::write(settings_path, contents) .unwrap_or_else(|err| panic!("failed to write {}: {err}", settings_path.display())); @@ -502,6 +516,48 @@ fn sync_home_settings( write_settings_table(settings_path, &table); } +fn has_explicit_storage_root(table: &TomlMap) -> bool { + table + .get("server") + .and_then(TomlValue::as_table) + .and_then(|server| server.get("storage")) + .and_then(TomlValue::as_table) + .and_then(|storage| storage.get("root")) + .is_some() +} + +fn set_server_storage_root(table: &mut TomlMap, storage_dir: &Path) { + let server_entry = table + .entry("server".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(server_table) = server_entry.as_table_mut() else { + panic!("expected [server] to be a TOML table"); + }; + let storage_entry = server_table + .entry("storage".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(storage_table) = storage_entry.as_table_mut() else { + panic!("expected [server.storage] to be a TOML table"); + }; + storage_table.insert( + "root".to_string(), + TomlValue::String(storage_dir.display().to_string()), + ); +} + +fn clear_server_storage(table: &mut TomlMap) { + let Some(server_entry) = table.get_mut("server") else { + return; + }; + let Some(server_table) = server_entry.as_table_mut() else { + return; + }; + server_table.remove("storage"); + if server_table.is_empty() { + table.remove("server"); + } +} + fn server_record_path(storage_dir: &Path) -> PathBuf { storage_dir.join("server.json") } diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index 9ca57b410..5684113ff 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -27,4 +27,8 @@ hex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +toml.workspace = true ulid.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/lib/crates/fabro-types/src/combine.rs b/lib/crates/fabro-types/src/combine.rs deleted file mode 100644 index 6014cb957..000000000 --- a/lib/crates/fabro-types/src/combine.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::HashMap; -use std::hash::Hash; -use std::path::PathBuf; - -pub trait Combine { - #[must_use] - fn combine(self, other: Self) -> Self; -} - -impl Combine for Option { - fn combine(self, other: Self) -> Self { - match (self, other) { - (Some(this), Some(other)) => Some(this.combine(other)), - (Some(this), None) => Some(this), - (None, Some(other)) => Some(other), - (None, None) => None, - } - } -} - -impl Combine for Vec { - fn combine(mut self, other: Self) -> Self { - self.extend(other); - self - } -} - -impl Combine for HashMap -where - K: Eq + Hash, - V: Combine, -{ - fn combine(mut self, other: Self) -> Self { - for (key, value) in other { - match self.remove(&key) { - Some(existing) => { - self.insert(key, existing.combine(value)); - } - None => { - self.insert(key, value); - } - } - } - - self - } -} - -macro_rules! impl_left_wins { - ($($ty:ty),* $(,)?) => { - $( - impl Combine for $ty { - fn combine(self, _other: Self) -> Self { - self - } - } - )* - }; -} - -impl_left_wins!(bool, i32, u16, u32, u64, usize, String, PathBuf,); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 7dd665897..fd76dd445 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -3,7 +3,6 @@ extern crate self as fabro_types; pub mod billing; pub mod blob_ref; pub mod checkpoint; -pub mod combine; pub mod conclusion; pub mod failure_signature; pub mod graph; @@ -33,7 +32,6 @@ pub use blob_ref::{ }; pub use checkpoint::Checkpoint; pub use conclusion::{Conclusion, StageSummary}; -pub use fabro_macros::Combine; pub use failure_signature::FailureSignature; pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type}; pub use interview::{InterviewQuestionRecord, InterviewQuestionType}; @@ -53,7 +51,6 @@ pub use run_event::{ActorKind, ActorRef, EventBody, RunEvent, RunNoticeLevel}; pub use run_id::RunId; pub use run_id::fixtures; pub use sandbox_record::SandboxRecord; -pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings, Settings}; pub use stage_id::{ParallelBranchId, StageId}; pub use start::StartRecord; pub use status::{ diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 4c282d098..5ab442a6f 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::graph::Graph; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -use crate::settings::Settings; +use crate::settings::SettingsFile; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -52,7 +52,7 @@ pub struct RunProvenance { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunRecord { pub run_id: RunId, - pub settings: Settings, + pub settings: SettingsFile, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index b9949bbd6..18f6389ab 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -53,6 +53,15 @@ impl ActorRef { display: Some(login), } } + + #[must_use] + pub fn agent(session_id: Option, display: Option) -> Self { + Self { + kind: ActorKind::Agent, + id: session_id, + display, + } + } } #[derive(Debug, Clone, PartialEq)] @@ -768,7 +777,8 @@ mod tests { use serde_json::json; - use crate::{Edge, Graph, Node, RunBlobId, Settings, fixtures}; + use crate::settings::SettingsFile; + use crate::{Edge, Graph, Node, RunBlobId, fixtures}; use super::*; @@ -819,7 +829,7 @@ mod tests { #[test] fn run_event_deserializes_adjacent_layout() { - let settings = Settings::default(); + let settings = SettingsFile::default(); let graph = Graph { name: "test".to_string(), nodes: HashMap::from([( @@ -864,7 +874,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": Settings::default(), + "settings": SettingsFile::default(), "graph": Graph::new("test"), "labels": {}, "run_dir": "/tmp/run", diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index ce24c10e2..75fda5db0 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,13 +2,14 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, Settings, StatusReason}; +use crate::settings::SettingsFile; +use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason}; use super::{BilledTokenCounts, RunNoticeLevel}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCreatedProps { - pub settings: Settings, + pub settings: SettingsFile, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_source: Option, diff --git a/lib/crates/fabro-types/src/settings/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs new file mode 100644 index 000000000..aef8b2bbb --- /dev/null +++ b/lib/crates/fabro-types/src/settings/accessors.rs @@ -0,0 +1,656 @@ +//! Convenience accessors on [`SettingsFile`]. +//! +//! These methods provide ergonomic, flat-shaped views into the v2 parse +//! tree. They exist so that consumers don't have to chain `.as_ref()` +//! through every Option layer when reading common fields. Each accessor +//! walks the real v2 structure — there is no transitional state here. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::cli::{CliExecLayer, CliLayer, CliOutputLayer}; +use super::interp::InterpString; +use super::project::ProjectLayer; +use super::run::{ + ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, ResolvedGoalSource, ResolvedRunGoal, + RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGoalLayer, + RunLayer, RunMode, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, +}; +use super::server::{ + GithubIntegrationLayer, ServerApiLayer, ServerArtifactsLayer, ServerIntegrationsLayer, + ServerLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, + SlackIntegrationLayer, +}; +use super::tree::SettingsFile; + +impl SettingsFile { + // ---------- project-scope ---------- + + #[must_use] + pub fn project_layer(&self) -> Option<&ProjectLayer> { + self.project.as_ref() + } + + #[must_use] + pub fn project_directory(&self) -> Option<&str> { + self.project.as_ref().and_then(|p| p.directory.as_deref()) + } + + // ---------- run-scope ---------- + + #[must_use] + pub fn run_layer(&self) -> Option<&RunLayer> { + self.run.as_ref() + } + + /// Raw access to the `run.goal` variant (inline or file). + #[must_use] + pub fn run_goal_layer(&self) -> Option<&RunGoalLayer> { + self.run.as_ref().and_then(|r| r.goal.as_ref()) + } + + /// Inline goal text only. Returns `None` when `run.goal` is unset **or** + /// when it's a file-sourced goal — callers that need the file contents + /// should use [`SettingsFile::resolve_run_goal`]. + #[must_use] + pub fn run_goal_inline_str(&self) -> Option { + match self.run_goal_layer()? { + RunGoalLayer::Inline(s) => Some(s.as_source()), + RunGoalLayer::File { .. } => None, + } + } + + /// Resolve the `run.goal` layer to its final text, reading a file from + /// disk if necessary. + /// + /// Path resolution: + /// + /// - Absolute paths in the `file` variant are used as-is. + /// - Literal relative paths should already have been rewritten to + /// absolute at config-load time by + /// `fabro_config::resolve_goal_file_paths`. If one reaches this point + /// it will be resolved against `base_dir` as a fallback. + /// - `${env.NAME}` interpolation is resolved via `std::env::var` at + /// call time. Relative paths that survive interpolation are also + /// resolved against `base_dir`. + /// + /// Returns `Ok(None)` when `run.goal` is unset. Returns `Err` when the + /// file variant points at a path that can't be read or has an + /// unresolved env token. + pub fn resolve_run_goal( + &self, + base_dir: &Path, + ) -> Result, ResolveGoalError> { + let Some(layer) = self.run_goal_layer() else { + return Ok(None); + }; + match layer { + RunGoalLayer::Inline(s) => Ok(Some(ResolvedRunGoal { + text: s.as_source(), + source: ResolvedGoalSource::Inline, + })), + RunGoalLayer::File { file } => { + let resolved = file + .resolve(|name| std::env::var(name).ok()) + .map_err(|err| ResolveGoalError::EnvLookup { var: err.name })?; + let path = resolve_goal_file_path(&resolved.value, base_dir); + let text = std::fs::read_to_string(&path).map_err(|err| ResolveGoalError::Io { + path: path.clone(), + source: err, + })?; + Ok(Some(ResolvedRunGoal { + text, + source: ResolvedGoalSource::File { path }, + })) + } + } + } + + #[must_use] + pub fn run_working_dir(&self) -> Option<&InterpString> { + self.run.as_ref().and_then(|r| r.working_dir.as_ref()) + } + + #[must_use] + pub fn run_working_dir_str(&self) -> Option { + self.run_working_dir().map(InterpString::as_source) + } + + #[must_use] + pub fn run_model(&self) -> Option<&RunModelLayer> { + self.run.as_ref().and_then(|r| r.model.as_ref()) + } + + #[must_use] + pub fn run_model_name_str(&self) -> Option { + self.run_model() + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn run_model_provider_str(&self) -> Option { + self.run_model() + .and_then(|m| m.provider.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn run_sandbox(&self) -> Option<&RunSandboxLayer> { + self.run.as_ref().and_then(|r| r.sandbox.as_ref()) + } + + #[must_use] + pub fn run_prepare(&self) -> Option<&RunPrepareLayer> { + self.run.as_ref().and_then(|r| r.prepare.as_ref()) + } + + /// Flattened prepare-step commands: each `script` is kept as-is, and + /// `command` argv is joined with spaces. Env-interpolation tokens are + /// emitted verbatim via [`InterpString::as_source`]. + #[must_use] + pub fn run_prepare_commands(&self) -> Vec { + let Some(prepare) = self.run_prepare() else { + return Vec::new(); + }; + prepare + .steps + .iter() + .filter_map(|step| { + if let Some(script) = &step.script { + Some(script.as_source()) + } else { + step.command.as_ref().map(|argv| { + argv.iter() + .map(InterpString::as_source) + .collect::>() + .join(" ") + }) + } + }) + .collect() + } + + /// Prepare-step timeout in milliseconds. + #[must_use] + pub fn run_prepare_timeout_ms(&self) -> Option { + self.run_prepare() + .and_then(|p| p.timeout) + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)) + } + + #[must_use] + pub fn run_checkpoint(&self) -> Option<&RunCheckpointLayer> { + self.run.as_ref().and_then(|r| r.checkpoint.as_ref()) + } + + #[must_use] + pub fn run_hooks(&self) -> &[HookEntry] { + self.run.as_ref().map_or(&[], |r| r.hooks.as_slice()) + } + + #[must_use] + pub fn run_pull_request(&self) -> Option<&RunPullRequestLayer> { + self.run.as_ref().and_then(|r| r.pull_request.as_ref()) + } + + #[must_use] + pub fn run_artifacts(&self) -> Option<&RunArtifactsLayer> { + self.run.as_ref().and_then(|r| r.artifacts.as_ref()) + } + + #[must_use] + pub fn run_execution(&self) -> Option<&RunExecutionLayer> { + self.run.as_ref().and_then(|r| r.execution.as_ref()) + } + + #[must_use] + pub fn run_agent(&self) -> Option<&RunAgentLayer> { + self.run.as_ref().and_then(|r| r.agent.as_ref()) + } + + #[must_use] + pub fn run_agent_mcps(&self) -> Option<&HashMap> { + self.run_agent().map(|a| &a.mcps) + } + + #[must_use] + pub fn run_inputs(&self) -> Option<&HashMap> { + self.run.as_ref().and_then(|r| r.inputs.as_ref()) + } + + /// Stringified view of `run.inputs`: non-string TOML values are rendered + /// via their canonical TOML representation (integers, booleans, and + /// arrays are flattened through `Display`). Returns `None` when no + /// inputs are set. + #[must_use] + pub fn run_inputs_as_strings(&self) -> Option> { + self.run_inputs().map(|inputs| { + inputs + .iter() + .map(|(k, v)| { + let stringified = match v { + toml::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), stringified) + }) + .collect() + }) + } + + #[must_use] + pub fn run_metadata(&self) -> Option<&HashMap> { + self.run.as_ref().map(|r| &r.metadata) + } + + #[must_use] + pub fn run_git_author(&self) -> Option<&GitAuthorLayer> { + self.run + .as_ref() + .and_then(|r| r.git.as_ref()) + .and_then(|g| g.author.as_ref()) + } + + // ---------- execution-posture booleans ---------- + + #[must_use] + pub fn dry_run_enabled(&self) -> bool { + matches!( + self.run_execution().and_then(|e| e.mode), + Some(RunMode::DryRun) + ) + } + + #[must_use] + pub fn auto_approve_enabled(&self) -> bool { + matches!( + self.run_execution().and_then(|e| e.approval), + Some(ApprovalMode::Auto) + ) + } + + /// Returns `true` when retros are explicitly disabled. Defaults to + /// `false` (retros enabled) when not set. + #[must_use] + pub fn no_retro_enabled(&self) -> bool { + matches!(self.run_execution().and_then(|e| e.retros), Some(false)) + } + + #[must_use] + pub fn preserve_sandbox_enabled(&self) -> bool { + self.run_sandbox() + .and_then(|sb| sb.preserve) + .unwrap_or(false) + } + + // ---------- cli-scope ---------- + + #[must_use] + pub fn cli_layer(&self) -> Option<&CliLayer> { + self.cli.as_ref() + } + + #[must_use] + pub fn cli_exec(&self) -> Option<&CliExecLayer> { + self.cli.as_ref().and_then(|c| c.exec.as_ref()) + } + + #[must_use] + pub fn cli_output(&self) -> Option<&CliOutputLayer> { + self.cli.as_ref().and_then(|c| c.output.as_ref()) + } + + #[must_use] + pub fn verbose_enabled(&self) -> bool { + use super::cli::OutputVerbosity; + matches!( + self.cli_output().and_then(|o| o.verbosity), + Some(OutputVerbosity::Verbose) + ) + } + + #[must_use] + pub fn prevent_idle_sleep_enabled(&self) -> bool { + self.cli_exec() + .and_then(|e| e.prevent_idle_sleep) + .unwrap_or(false) + } + + /// Upgrade check defaults to `true` when unset. + #[must_use] + pub fn upgrade_check_enabled(&self) -> bool { + self.cli + .as_ref() + .and_then(|c| c.updates.as_ref()) + .and_then(|u| u.check) + .unwrap_or(true) + } + + // ---------- server-scope ---------- + + #[must_use] + pub fn server_layer(&self) -> Option<&ServerLayer> { + self.server.as_ref() + } + + #[must_use] + pub fn server_api(&self) -> Option<&ServerApiLayer> { + self.server.as_ref().and_then(|s| s.api.as_ref()) + } + + #[must_use] + pub fn server_web(&self) -> Option<&ServerWebLayer> { + self.server.as_ref().and_then(|s| s.web.as_ref()) + } + + #[must_use] + pub fn server_storage(&self) -> Option<&ServerStorageLayer> { + self.server.as_ref().and_then(|s| s.storage.as_ref()) + } + + #[must_use] + pub fn server_storage_root_str(&self) -> Option { + self.server_storage() + .and_then(|s| s.root.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn server_artifacts(&self) -> Option<&ServerArtifactsLayer> { + self.server.as_ref().and_then(|s| s.artifacts.as_ref()) + } + + #[must_use] + pub fn server_scheduler(&self) -> Option<&ServerSchedulerLayer> { + self.server.as_ref().and_then(|s| s.scheduler.as_ref()) + } + + #[must_use] + pub fn max_concurrent_runs(&self) -> Option { + self.server_scheduler().and_then(|s| s.max_concurrent_runs) + } + + #[must_use] + pub fn server_logging(&self) -> Option<&ServerLoggingLayer> { + self.server.as_ref().and_then(|s| s.logging.as_ref()) + } + + #[must_use] + pub fn server_integrations(&self) -> Option<&ServerIntegrationsLayer> { + self.server.as_ref().and_then(|s| s.integrations.as_ref()) + } + + #[must_use] + pub fn server_integrations_github(&self) -> Option<&GithubIntegrationLayer> { + self.server_integrations().and_then(|i| i.github.as_ref()) + } + + #[must_use] + pub fn server_integrations_slack(&self) -> Option<&SlackIntegrationLayer> { + self.server_integrations().and_then(|i| i.slack.as_ref()) + } + + #[must_use] + pub fn github_app_id_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.app_id.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_client_id_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.client_id.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_slug_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.slug.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_permissions(&self) -> Option<&HashMap> { + self.server_integrations_github() + .map(|g| &g.permissions) + .filter(|m| !m.is_empty()) + } + + // ---------- storage path with home-dir default ---------- + + /// Returns the configured server storage root, or the home-dir default + /// when unset. Env interpolation is resolved at read time against the + /// process environment. + #[must_use] + pub fn storage_dir(&self) -> PathBuf { + self.server_storage() + .and_then(|s| s.root.as_ref()) + .and_then(|interp| { + interp + .resolve(|name| std::env::var(name).ok()) + .ok() + .map(|resolved| resolved.value) + }) + .map_or_else(|| fabro_util::Home::from_env().storage_dir(), PathBuf::from) + } + + // ---------- labels / metadata aggregation ---------- + + /// Combined metadata labels from project, workflow, and run layers. + /// Later layers overwrite earlier ones (project < workflow < run). + #[must_use] + pub fn all_labels(&self) -> HashMap { + let mut out = HashMap::new(); + if let Some(project) = &self.project { + for (k, v) in &project.metadata { + out.insert(k.clone(), v.clone()); + } + } + if let Some(workflow) = &self.workflow { + for (k, v) in &workflow.metadata { + out.insert(k.clone(), v.clone()); + } + } + if let Some(run) = &self.run { + for (k, v) in &run.metadata { + out.insert(k.clone(), v.clone()); + } + } + out + } +} + +/// Resolve a goal-file path string against `base_dir`. Absolute paths are +/// used as-is; relative paths are joined onto `base_dir`. +#[must_use] +pub fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf { + let path = Path::new(path_str); + if path.is_absolute() { + path.to_path_buf() + } else { + base_dir.join(path) + } +} + +/// Error returned by [`SettingsFile::resolve_run_goal`]. +#[derive(Debug)] +pub enum ResolveGoalError { + /// The `run.goal.file` InterpString referenced an env var that wasn't + /// set at consume time. + EnvLookup { var: String }, + /// The goal file exists in config but could not be read. + Io { + path: PathBuf, + source: std::io::Error, + }, +} + +impl std::fmt::Display for ResolveGoalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EnvLookup { var } => write!( + f, + "failed to resolve run.goal.file: env var {var:?} referenced by ${{env.{var}}} is not set" + ), + Self::Io { path, .. } => { + write!(f, "failed to read run.goal.file at {}", path.display()) + } + } + } +} + +impl std::error::Error for ResolveGoalError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::EnvLookup { .. } => None, + Self::Io { source, .. } => Some(source), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::run::{RunLayer, RunModelLayer}; + + #[test] + fn run_goal_inline_str_returns_source_value() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("Implement OAuth"))), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!( + file.run_goal_inline_str().as_deref(), + Some("Implement OAuth") + ); + } + + #[test] + fn run_goal_inline_str_is_none_for_file_variant() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::File { + file: InterpString::parse("/abs/goal.md"), + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!(file.run_goal_inline_str(), None); + assert!(matches!( + file.run_goal_layer(), + Some(RunGoalLayer::File { .. }) + )); + } + + #[test] + fn resolve_run_goal_reads_file_variant_from_disk() { + let tmp = tempfile::tempdir().unwrap(); + let goal_path = tmp.path().join("goal.md"); + std::fs::write(&goal_path, "ship the thing").unwrap(); + + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::File { + file: InterpString::parse(goal_path.to_str().unwrap()), + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + + let resolved = file + .resolve_run_goal(tmp.path()) + .expect("goal file should resolve") + .expect("goal should be set"); + assert_eq!(resolved.text, "ship the thing"); + assert!(matches!( + resolved.source, + ResolvedGoalSource::File { ref path } if path == &goal_path + )); + } + + #[test] + fn resolve_run_goal_inline_passes_text_through() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("literal goal"))), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let resolved = file + .resolve_run_goal(std::path::Path::new("/")) + .unwrap() + .unwrap(); + assert_eq!(resolved.text, "literal goal"); + assert_eq!(resolved.source, ResolvedGoalSource::Inline); + } + + #[test] + fn run_model_name_str_walks_tree() { + let file = SettingsFile { + run: Some(RunLayer { + model: Some(RunModelLayer { + name: Some(InterpString::parse("claude-sonnet-4-6")), + ..RunModelLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!( + file.run_model_name_str().as_deref(), + Some("claude-sonnet-4-6") + ); + } + + #[test] + fn upgrade_check_defaults_to_true_when_unset() { + let file = SettingsFile::default(); + assert!(file.upgrade_check_enabled()); + } + + #[test] + fn all_labels_merges_project_workflow_run() { + use crate::settings::project::ProjectLayer; + use crate::settings::workflow::WorkflowLayer; + + let mut project_metadata = HashMap::new(); + project_metadata.insert("env".into(), "project".into()); + project_metadata.insert("team".into(), "core".into()); + + let mut workflow_metadata = HashMap::new(); + workflow_metadata.insert("env".into(), "workflow".into()); + + let mut run_metadata = HashMap::new(); + run_metadata.insert("priority".into(), "high".into()); + + let file = SettingsFile { + project: Some(ProjectLayer { + metadata: project_metadata, + ..ProjectLayer::default() + }), + workflow: Some(WorkflowLayer { + metadata: workflow_metadata, + ..WorkflowLayer::default() + }), + run: Some(RunLayer { + metadata: run_metadata, + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + + let labels = file.all_labels(); + assert_eq!(labels.get("env").map(String::as_str), Some("workflow")); + assert_eq!(labels.get("team").map(String::as_str), Some("core")); + assert_eq!(labels.get("priority").map(String::as_str), Some("high")); + } +} diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs new file mode 100644 index 000000000..1e0dd37b0 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -0,0 +1,147 @@ +//! CLI domain. +//! +//! `[cli]` is owner-first: the CLI process reads its settings from +//! `~/.fabro/settings.toml` plus process-local overrides. `cli.*` stanzas in +//! `fabro.toml` and `workflow.toml` remain schema-valid but runtime-inert. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::interp::InterpString; +use super::run::{AgentPermissions, McpEntryLayer}; + +/// A sparse `[cli]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updates: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, +} + +/// `[cli.target]` — explicit transport selection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum CliTargetLayer { + Http { + #[serde(default)] + url: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliTargetTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[cli.auth]` — explicit auth strategy selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliAuthLayer { + /// `none` explicitly disables inherited auth. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliAuthStrategy { + None, + Jwt, + Mtls, +} + +/// `[cli.exec]` — `fabro exec` defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecLayer { + /// Prevent idle sleep on macOS while an exec run is in flight. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prevent_idle_sleep: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP entries for `fabro exec`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +/// `[cli.output]` — generic CLI output defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliOutputLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbosity: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputVerbosity { + Quiet, + Normal, + Verbose, +} + +/// `[cli.updates]` — upgrade check toggle. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliUpdatesLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check: Option, +} + +/// `[cli.logging]` — process-owned logging configuration for the CLI. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliLoggingLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} diff --git a/lib/crates/fabro-types/src/settings/duration.rs b/lib/crates/fabro-types/src/settings/duration.rs new file mode 100644 index 000000000..f3698fd9e --- /dev/null +++ b/lib/crates/fabro-types/src/settings/duration.rs @@ -0,0 +1,303 @@ +//! Config-facing duration values. +//! +//! Accepts a single-unit suffix per value: `ms`, `s`, `m`, `h`, or `d`. +//! Composed values like `1h30m` are rejected. The canonical renderer emits +//! the same single-unit form. + +use std::fmt; +use std::str::FromStr; +use std::time::Duration as StdDuration; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A duration parsed from a single-unit human-readable string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Duration(StdDuration); + +impl Duration { + #[must_use] + pub const fn from_std(duration: StdDuration) -> Self { + Self(duration) + } + + #[must_use] + pub const fn as_std(&self) -> StdDuration { + self.0 + } + + #[must_use] + pub const fn from_secs(secs: u64) -> Self { + Self(StdDuration::from_secs(secs)) + } + + #[must_use] + pub const fn from_millis(millis: u64) -> Self { + Self(StdDuration::from_millis(millis)) + } +} + +impl From for Duration { + fn from(value: StdDuration) -> Self { + Self(value) + } +} + +impl From for StdDuration { + fn from(value: Duration) -> Self { + value.0 + } +} + +/// An error returned when parsing a duration string fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseDurationError { + /// The input was empty or whitespace only. + Empty, + /// The input had a numeric portion that could not be parsed as a non-negative integer. + InvalidNumber { input: String }, + /// The input had no unit suffix. + MissingUnit { input: String }, + /// The input had an unrecognized unit suffix. + InvalidUnit { input: String, unit: String }, + /// The input contained more than one unit (e.g. `1h30m`), which is not supported. + Composed { input: String }, +} + +impl fmt::Display for ParseDurationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("duration value is empty"), + Self::InvalidNumber { input } => { + write!( + f, + "duration {input:?}: numeric portion is not a non-negative integer" + ) + } + Self::MissingUnit { input } => { + write!( + f, + "duration {input:?}: missing unit suffix (expected one of ms, s, m, h, d)" + ) + } + Self::InvalidUnit { input, unit } => { + write!( + f, + "duration {input:?}: unknown unit {unit:?} (expected one of ms, s, m, h, d)" + ) + } + Self::Composed { input } => { + write!( + f, + "duration {input:?}: composed values like 1h30m are not supported; use the smallest needed unit instead" + ) + } + } + } +} + +impl std::error::Error for ParseDurationError {} + +impl FromStr for Duration { + type Err = ParseDurationError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseDurationError::Empty); + } + + // Split numeric prefix from alphabetic suffix. Reject interior alpha runs. + let (num, unit) = + split_number_and_suffix(trimmed).ok_or_else(|| ParseDurationError::MissingUnit { + input: input.to_owned(), + })?; + + if num.is_empty() { + return Err(ParseDurationError::InvalidNumber { + input: input.to_owned(), + }); + } + + // A composed value like "1h30m" leaves digits inside the suffix. + if unit.chars().any(|c| c.is_ascii_digit()) { + return Err(ParseDurationError::Composed { + input: input.to_owned(), + }); + } + + let n: u64 = num.parse().map_err(|_| ParseDurationError::InvalidNumber { + input: input.to_owned(), + })?; + + let duration = match unit { + "ms" => StdDuration::from_millis(n), + "s" => StdDuration::from_secs(n), + "m" => StdDuration::from_secs(n.saturating_mul(60)), + "h" => StdDuration::from_secs(n.saturating_mul(60 * 60)), + "d" => StdDuration::from_secs(n.saturating_mul(24 * 60 * 60)), + other => { + return Err(ParseDurationError::InvalidUnit { + input: input.to_owned(), + unit: other.to_owned(), + }); + } + }; + + Ok(Self(duration)) + } +} + +/// Split a trimmed string into `(numeric_prefix, alphabetic_suffix)`. +/// +/// Returns `None` if there is no alphabetic suffix or the string is all alpha. +fn split_number_and_suffix(input: &str) -> Option<(&str, &str)> { + let first_alpha = input.find(|c: char| !c.is_ascii_digit())?; + if first_alpha == 0 { + return None; + } + Some((&input[..first_alpha], &input[first_alpha..])) +} + +impl fmt::Display for Duration { + /// Canonical rendering picks the largest unit that represents the value as an + /// integer multiple, preferring `d`, `h`, `m`, `s`, `ms` in that order. + /// Zero renders as `0s`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + const MS_PER_S: u128 = 1_000; + const MS_PER_M: u128 = 60 * MS_PER_S; + const MS_PER_H: u128 = 60 * MS_PER_M; + const MS_PER_D: u128 = 24 * MS_PER_H; + + let total_ms = self.0.as_millis(); + if total_ms == 0 { + return f.write_str("0s"); + } + if total_ms.is_multiple_of(MS_PER_D) { + write!(f, "{}d", total_ms / MS_PER_D) + } else if total_ms.is_multiple_of(MS_PER_H) { + write!(f, "{}h", total_ms / MS_PER_H) + } else if total_ms.is_multiple_of(MS_PER_M) { + write!(f, "{}m", total_ms / MS_PER_M) + } else if total_ms.is_multiple_of(MS_PER_S) { + write!(f, "{}s", total_ms / MS_PER_S) + } else { + write!(f, "{total_ms}ms") + } + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize>(deserializer: D) -> Result { + struct DurationVisitor; + + impl Visitor<'_> for DurationVisitor { + type Value = Duration; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(r#"a duration string such as "30s", "1m", or "1h""#) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + } + + deserializer.deserialize_str(DurationVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_unit() { + assert_eq!( + "500ms".parse::().unwrap(), + Duration::from_millis(500) + ); + assert_eq!("30s".parse::().unwrap(), Duration::from_secs(30)); + assert_eq!("2m".parse::().unwrap(), Duration::from_secs(120)); + assert_eq!( + "1h".parse::().unwrap(), + Duration::from_secs(3_600) + ); + assert_eq!( + "1d".parse::().unwrap(), + Duration::from_secs(86_400) + ); + } + + #[test] + fn rejects_composed_values() { + let err = "1h30m".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::Composed { .. })); + } + + #[test] + fn rejects_missing_unit() { + let err = "30".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::MissingUnit { .. })); + } + + #[test] + fn rejects_unknown_unit() { + let err = "1w".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::InvalidUnit { unit, .. } if unit == "w")); + } + + #[test] + fn rejects_empty() { + let err = "".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::Empty)); + } + + #[test] + fn canonical_render_picks_largest_unit() { + assert_eq!(Duration::from_secs(86_400).to_string(), "1d"); + assert_eq!(Duration::from_secs(3_600).to_string(), "1h"); + assert_eq!(Duration::from_secs(120).to_string(), "2m"); + assert_eq!(Duration::from_secs(30).to_string(), "30s"); + assert_eq!(Duration::from_millis(500).to_string(), "500ms"); + assert_eq!(Duration::from_millis(0).to_string(), "0s"); + } + + #[test] + fn canonical_render_rounds_down_units_that_do_not_divide() { + // 90s cannot render as a whole number of minutes. + assert_eq!(Duration::from_secs(90).to_string(), "90s"); + } + + #[test] + fn round_trip_through_parse_and_display() { + for input in ["500ms", "30s", "1m", "2h", "3d"] { + let parsed: Duration = input.parse().unwrap(); + assert_eq!(parsed.to_string(), input); + } + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + d: Duration, + } + + let input = r#"{"d":"30s"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.d, Duration::from_secs(30)); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, r#"{"d":"30s"}"#); + } +} diff --git a/lib/crates/fabro-types/src/settings/features.rs b/lib/crates/fabro-types/src/settings/features.rs new file mode 100644 index 000000000..a590076ff --- /dev/null +++ b/lib/crates/fabro-types/src/settings/features.rs @@ -0,0 +1,17 @@ +//! Features domain. +//! +//! `[features]` is a reserved cross-cutting namespace for Fabro capability +//! flags only. It has a high admission bar and must not become a junk drawer. + +use serde::{Deserialize, Serialize}; + +/// A sparse `[features]` layer as it appears in a single settings file. +/// +/// Every field is an `Option` so layers can independently set or +/// override a flag without forcing a default that hides an unset value. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FeaturesLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_sandboxes: Option, +} diff --git a/lib/crates/fabro-types/src/settings/hook.rs b/lib/crates/fabro-types/src/settings/hook.rs deleted file mode 100644 index 69309b9c5..000000000 --- a/lib/crates/fabro-types/src/settings/hook.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::borrow::Cow; - -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>, - #[serde(default)] - allowed_env_vars: Vec, - #[serde(default)] - tls: TlsMode, - }, - Prompt { - prompt: String, - model: Option, - }, - Agent { - prompt: String, - model: Option, - max_tool_rounds: Option, - }, -} - -/// A single hook definition. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct HookDefinition { - pub name: Option, - pub event: HookEvent, - /// Inline command shorthand — if set, implies `type = "command"`. - #[serde(default)] - pub command: Option, - /// Explicit hook type (command or http). If omitted and `command` is set, - /// defaults to `Command`. - #[serde(flatten)] - pub hook_type: Option, - /// Regex matched against node_id, handler_type, or event-specific fields. - pub matcher: Option, - /// Override the event's default blocking behavior. - pub blocking: Option, - /// Timeout in milliseconds (default: 60_000). - pub timeout_ms: Option, - /// Run inside the sandbox (true, default) or on the host (false). - pub sandbox: Option, -} - -impl HookDefinition { - /// Resolve the effective hook type: explicit `hook_type` wins, then `command` - /// shorthand, then error. - pub fn resolved_hook_type(&self) -> Option> { - 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, -} - -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 = - std::collections::HashMap::new(); - let mut order: Vec = 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 } - } -} diff --git a/lib/crates/fabro-types/src/settings/interp.rs b/lib/crates/fabro-types/src/settings/interp.rs new file mode 100644 index 000000000..ef8e43b3a --- /dev/null +++ b/lib/crates/fabro-types/src/settings/interp.rs @@ -0,0 +1,335 @@ +//! Env var interpolation for config strings. +//! +//! Any string field may use `${env.NAME}` tokens, either as a whole value or +//! as one or more substrings inside a larger string. Resolution happens only +//! when the field is consumed, and provenance tracking lets outward-facing +//! renderers redact env-sourced values uniformly. + +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A config string that may contain `${env.NAME}` tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterpString { + segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + EnvVar(String), +} + +impl InterpString { + /// Parse a raw string into its literal/env-var segments. + /// + /// Parsing is infallible: the token grammar is intentionally permissive so + /// that validation happens at consumption time along with env lookup. + #[must_use] + pub fn parse(input: &str) -> Self { + let mut segments: Vec = Vec::new(); + let mut rest = input; + + while let Some(start) = rest.find("${env.") { + if start > 0 { + segments.push(Segment::Literal(rest[..start].to_owned())); + } + // rest[start..] begins with "${env." + let after_prefix = &rest[start + "${env.".len()..]; + if let Some(close) = after_prefix.find('}') { + let name = after_prefix[..close].to_owned(); + segments.push(Segment::EnvVar(name)); + rest = &after_prefix[close + 1..]; + } else { + // Unterminated token — treat the remainder as literal text. + segments.push(Segment::Literal(rest[start..].to_owned())); + rest = ""; + break; + } + } + + if !rest.is_empty() { + segments.push(Segment::Literal(rest.to_owned())); + } + + if segments.is_empty() { + segments.push(Segment::Literal(String::new())); + } + + Self { segments } + } + + /// True when this string contains no env var tokens. + #[must_use] + pub fn is_literal(&self) -> bool { + self.segments + .iter() + .all(|seg| matches!(seg, Segment::Literal(_))) + } + + /// True when this string contains at least one env var token. + #[must_use] + pub fn references_env(&self) -> bool { + self.segments + .iter() + .any(|seg| matches!(seg, Segment::EnvVar(_))) + } + + /// The env var names referenced by this string, in source order. + #[must_use] + pub fn env_var_names(&self) -> Vec<&str> { + self.segments + .iter() + .filter_map(|seg| match seg { + Segment::EnvVar(name) => Some(name.as_str()), + Segment::Literal(_) => None, + }) + .collect() + } + + /// The raw source string. + #[must_use] + pub fn as_source(&self) -> String { + let mut out = String::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => out.push_str(text), + Segment::EnvVar(name) => { + out.push_str("${env."); + out.push_str(name); + out.push('}'); + } + } + } + out + } + + /// Resolve this string using `lookup`, which should return the current + /// value for a given env var name (or `None` if unset). + /// + /// On success the caller gets the final string plus provenance describing + /// whether any env var contributed to the value. On failure the caller + /// learns which env var was unresolved. + pub fn resolve(&self, mut lookup: F) -> Result + where + F: FnMut(&str) -> Option, + { + let mut value = String::new(); + let mut used = Vec::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => value.push_str(text), + Segment::EnvVar(name) => { + let Some(resolved) = lookup(name) else { + return Err(ResolveEnvError { name: name.clone() }); + }; + value.push_str(&resolved); + used.push(name.clone()); + } + } + } + + let provenance = if used.is_empty() { + Provenance::Literal + } else { + Provenance::EnvSourced { names: used } + }; + Ok(Resolved { value, provenance }) + } +} + +impl From for InterpString { + fn from(value: String) -> Self { + Self::parse(&value) + } +} + +impl From<&str> for InterpString { + fn from(value: &str) -> Self { + Self::parse(value) + } +} + +/// The outcome of a successful env interpolation resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + pub value: String, + pub provenance: Provenance, +} + +/// Provenance metadata for resolved config values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Provenance { + /// No env var contributed to this value. + Literal, + /// One or more env vars contributed to this value. Used by outward-facing + /// renderers to redact env-sourced values uniformly. + EnvSourced { names: Vec }, +} + +/// An error returned when an env var referenced in a config string is not set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveEnvError { + pub name: String, +} + +impl fmt::Display for ResolveEnvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "environment variable {:?} referenced by ${{env.{}}} is not set", + self.name, self.name + ) + } +} + +impl std::error::Error for ResolveEnvError {} + +impl Serialize for InterpString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_source()) + } +} + +impl<'de> Deserialize<'de> for InterpString { + fn deserialize>(deserializer: D) -> Result { + struct InterpStringVisitor; + + impl Visitor<'_> for InterpStringVisitor { + type Value = InterpString; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a string, optionally containing ${env.NAME} interpolation tokens") + } + + fn visit_str(self, value: &str) -> Result { + Ok(InterpString::parse(value)) + } + + fn visit_string(self, value: String) -> Result { + Ok(InterpString::parse(&value)) + } + } + + deserializer.deserialize_str(InterpStringVisitor) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn lookup_from(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option + 'static { + let map: HashMap = values + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + move |name| map.get(name).cloned() + } + + #[test] + fn literal_string_has_no_env_refs() { + let s = InterpString::parse("hello world"); + assert!(s.is_literal()); + assert!(!s.references_env()); + assert_eq!(s.env_var_names(), Vec::<&str>::new()); + } + + #[test] + fn whole_value_env_reference() { + let s = InterpString::parse("${env.API_KEY}"); + assert!(!s.is_literal()); + assert_eq!(s.env_var_names(), vec!["API_KEY"]); + assert_eq!(s.as_source(), "${env.API_KEY}"); + } + + #[test] + fn substring_env_reference() { + let s = InterpString::parse("Bearer ${env.TOKEN}"); + assert_eq!(s.env_var_names(), vec!["TOKEN"]); + } + + #[test] + fn multi_token_env_reference() { + let s = InterpString::parse("${env.USER}@${env.HOST}:${env.PORT}"); + assert_eq!(s.env_var_names(), vec!["USER", "HOST", "PORT"]); + } + + #[test] + fn resolve_literal_string() { + let s = InterpString::parse("static"); + let resolved = s.resolve(lookup_from(&[])).unwrap(); + assert_eq!(resolved.value, "static"); + assert_eq!(resolved.provenance, Provenance::Literal); + } + + #[test] + fn resolve_whole_value() { + let s = InterpString::parse("${env.API_KEY}"); + let resolved = s + .resolve(lookup_from(&[("API_KEY", "secret-123")])) + .unwrap(); + assert_eq!(resolved.value, "secret-123"); + assert_eq!( + resolved.provenance, + Provenance::EnvSourced { + names: vec!["API_KEY".into()] + } + ); + } + + #[test] + fn resolve_substring() { + let s = InterpString::parse("Bearer ${env.TOKEN}"); + let resolved = s.resolve(lookup_from(&[("TOKEN", "abc")])).unwrap(); + assert_eq!(resolved.value, "Bearer abc"); + } + + #[test] + fn resolve_multiple_tokens() { + let s = InterpString::parse("${env.USER}@${env.HOST}"); + let resolved = s + .resolve(lookup_from(&[("USER", "root"), ("HOST", "example.com")])) + .unwrap(); + assert_eq!(resolved.value, "root@example.com"); + assert_eq!( + resolved.provenance, + Provenance::EnvSourced { + names: vec!["USER".into(), "HOST".into()] + } + ); + } + + #[test] + fn resolve_missing_env_fails_with_name() { + let s = InterpString::parse("${env.MISSING}"); + let err = s.resolve(lookup_from(&[])).unwrap_err(); + assert_eq!(err.name, "MISSING"); + } + + #[test] + fn unterminated_token_treated_as_literal() { + let s = InterpString::parse("${env.OPEN"); + let resolved = s.resolve(lookup_from(&[])).unwrap(); + assert_eq!(resolved.value, "${env.OPEN"); + assert_eq!(resolved.provenance, Provenance::Literal); + } + + #[test] + fn serde_round_trip_preserves_token_form() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + s: InterpString, + } + + let input = r#"{"s":"Bearer ${env.TOKEN}"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s.as_source(), "Bearer ${env.TOKEN}"); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } +} diff --git a/lib/crates/fabro-types/src/settings/mcp.rs b/lib/crates/fabro-types/src/settings/mcp.rs deleted file mode 100644 index 63d3bf5ac..000000000 --- a/lib/crates/fabro-types/src/settings/mcp.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::combine::Combine; - -pub fn default_startup_timeout_secs() -> u64 { - 10 -} - -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, - #[serde(default)] - env: HashMap, - }, - Http { - url: String, - #[serde(default)] - headers: HashMap, - }, - /// 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, - port: u16, - #[serde(default)] - env: HashMap, - }, -} - -impl Combine for McpTransport { - fn combine(self, _other: Self) -> Self { - self - } -} - -/// 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 { - 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, - } - } -} - -impl Combine for McpServerEntry { - fn combine(self, _other: Self) -> Self { - self - } -} - -#[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); - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 229b65b36..c1ff000b9 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,209 +1,41 @@ -use std::collections::HashMap; -use std::path::PathBuf; +//! Namespaced settings schema. +//! +//! Top-level schema is strictly namespaced with `_version`, `[project]`, +//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. +//! Value-language helpers live alongside the tree: durations, byte sizes, +//! model references, env interpolation, and splice-capable arrays. +//! +//! Stage 6.5b promoted these modules up out of the transitional +//! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer +//! exists. -use serde::{Deserialize, Serialize}; - -pub mod hook; -pub mod mcp; +pub mod accessors; +pub mod cli; +pub mod duration; +pub mod features; +pub mod interp; +pub mod model_ref; pub mod project; pub mod run; -pub mod sandbox; pub mod server; -pub mod user; +pub mod size; +pub mod splice_array; +pub mod tree; +pub mod version; +pub mod workflow; -pub use hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; -pub use mcp::{ - McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs, - default_tool_timeout_secs, +pub use cli::CliLayer; +pub use duration::{Duration, ParseDurationError}; +pub use features::FeaturesLayer; +pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; +pub use model_ref::{ + AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, }; -pub use project::ProjectSettings; -pub use run::{ - ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, - PullRequestSettings, SetupSettings, -}; -pub use sandbox::{ - DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, - LocalSandboxSettings, SandboxSettings, WorktreeMode, -}; -pub use server::{ - ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, - AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, - SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy, -}; -pub use user::{ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings}; - -fn is_default_checkpoint(c: &CheckpointSettings) -> bool { - c.exclude_globs.is_empty() -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct Settings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal_file: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, - #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] - pub work_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub setup: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vars: Option>, - #[serde(default, skip_serializing_if = "is_default_checkpoint")] - pub checkpoint: CheckpointSettings, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcp_servers: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_check: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dry_run: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_approve: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_retro: Option, - #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] - pub storage_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_storage: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fabro: Option, -} - -impl Settings { - pub fn app_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.app_id.as_deref()) - } - - pub fn slug(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.slug.as_deref()) - } - - pub fn client_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.client_id.as_deref()) - } - - pub fn git_author(&self) -> Option<&GitAuthorSettings> { - self.git.as_ref().map(|g| &g.author) - } - - pub fn sandbox_settings(&self) -> Option<&SandboxSettings> { - self.sandbox.as_ref() - } - - pub fn setup_settings(&self) -> Option<&SetupSettings> { - self.setup.as_ref() - } - - pub fn setup_commands(&self) -> &[String] { - self.setup - .as_ref() - .map_or(&[], |setup| setup.commands.as_slice()) - } - - pub fn setup_timeout_ms(&self) -> Option { - self.setup.as_ref().and_then(|setup| setup.timeout_ms) - } - - pub fn preserve_sandbox_enabled(&self) -> bool { - self.sandbox - .as_ref() - .and_then(|sandbox| sandbox.preserve) - .unwrap_or(false) - } - - pub fn github_permissions(&self) -> Option<&HashMap> { - self.github - .as_ref() - .and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions)) - } - - pub fn mcp_server_entries(&self) -> &HashMap { - &self.mcp_servers - } - - pub fn verbose_enabled(&self) -> bool { - self.verbose.unwrap_or(false) - } - - pub fn prevent_idle_sleep_enabled(&self) -> bool { - self.prevent_idle_sleep.unwrap_or(false) - } - - pub fn upgrade_check_enabled(&self) -> bool { - self.upgrade_check.unwrap_or(true) - } - - pub fn dry_run_enabled(&self) -> bool { - self.dry_run.unwrap_or(false) - } - - pub fn auto_approve_enabled(&self) -> bool { - self.auto_approve.unwrap_or(false) - } - - pub fn no_retro_enabled(&self) -> bool { - self.no_retro.unwrap_or(false) - } - - pub fn storage_dir(&self) -> PathBuf { - self.storage_dir - .clone() - .unwrap_or_else(|| fabro_util::Home::from_env().storage_dir()) - } - - pub fn slack_settings(&self) -> Option<&SlackSettings> { - self.slack.as_ref() - } -} - -#[cfg(test)] -mod tests { - use super::Settings; - - #[test] - fn storage_dir_defaults_to_home_storage_subdir() { - assert_eq!( - Settings::default().storage_dir(), - fabro_util::Home::from_env().storage_dir() - ); - } -} +pub use project::ProjectLayer; +pub use run::RunLayer; +pub use server::ServerLayer; +pub use size::{ParseSizeError, Size}; +pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; +pub use tree::{ParseError, SettingsFile, parse_settings_file}; +pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version}; +pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-types/src/settings/model_ref.rs b/lib/crates/fabro-types/src/settings/model_ref.rs new file mode 100644 index 000000000..6b2514559 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/model_ref.rs @@ -0,0 +1,375 @@ +//! Model references for `run.model.fallbacks`. +//! +//! Each entry is one of: +//! +//! - a bare token such as `openai` or `gpt-5.4` — the parser cannot tell alone +//! whether the token is a provider name or a model alias +//! - a qualified reference such as `gemini/gemini-flash`, which names both a +//! provider and a model +//! +//! The parser produces [`ModelRef`]; ambiguity resolution against a known +//! registry of providers and models happens at consumption time via +//! [`ModelRef::resolve`]. + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A parsed model reference. Bare tokens remain ambiguous until resolved +/// against a registry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelRef { + /// A bare token. May be a provider name, a model alias, or a model id. + Bare(String), + /// A provider-qualified model reference. + Qualified { provider: String, model: String }, +} + +/// An error returned when parsing a model reference fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseModelRefError { + /// The input was empty or whitespace only. + Empty, + /// The input contained more than one `/`, which is not a valid qualified ref. + TooManySlashes { input: String }, + /// The provider or model side of a qualified reference was empty. + EmptySide { input: String }, +} + +impl fmt::Display for ParseModelRefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("model reference is empty"), + Self::TooManySlashes { input } => { + write!( + f, + "model reference {input:?}: expected at most one \"/\" separator between provider and model" + ) + } + Self::EmptySide { input } => { + write!( + f, + "model reference {input:?}: provider and model sides must both be non-empty" + ) + } + } + } +} + +impl std::error::Error for ParseModelRefError {} + +impl FromStr for ModelRef { + type Err = ParseModelRefError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseModelRefError::Empty); + } + + let parts: Vec<&str> = trimmed.split('/').collect(); + match parts.as_slice() { + [bare] => Ok(Self::Bare((*bare).to_owned())), + [provider, model] => { + if provider.is_empty() || model.is_empty() { + Err(ParseModelRefError::EmptySide { + input: input.to_owned(), + }) + } else { + Ok(Self::Qualified { + provider: (*provider).to_owned(), + model: (*model).to_owned(), + }) + } + } + _ => Err(ParseModelRefError::TooManySlashes { + input: input.to_owned(), + }), + } + } +} + +impl fmt::Display for ModelRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bare(token) => f.write_str(token), + Self::Qualified { provider, model } => write!(f, "{provider}/{model}"), + } + } +} + +/// An error returned when resolving an ambiguous bare model reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AmbiguousModelRef { + pub input: String, + pub providers: Vec, + pub models: Vec, +} + +impl fmt::Display for AmbiguousModelRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "model reference {:?} is ambiguous: matches provider names {:?} and model names {:?}; qualify it as \"provider/model\"", + self.input, self.providers, self.models + ) + } +} + +impl std::error::Error for AmbiguousModelRef {} + +/// The resolved form of a model reference after registry lookup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedModelRef { + /// The reference named a provider; the runtime should pick the best model from that provider. + Provider(String), + /// The reference named a model (qualified or unambiguously bare). + Model { + provider: Option, + model: String, + }, +} + +/// A minimal registry view used by [`ModelRef::resolve`]. +/// +/// Each method reports whether a bare token is a known provider, model, or +/// both. The registry is abstract so unit tests and runtime resolution can +/// share the same logic. +pub trait ModelRegistry { + fn is_provider(&self, token: &str) -> bool; + fn is_model(&self, token: &str) -> bool; + /// Returns the canonical provider for a bare model token, when the registry + /// knows of a unique provider for that model. + fn provider_of(&self, token: &str) -> Option; +} + +impl ModelRef { + /// Resolve this reference against a registry. + /// + /// - [`ModelRef::Qualified`] always resolves to a model. + /// - [`ModelRef::Bare`] resolves to a provider if the token is only a provider, + /// to a model if the token is only a model, and returns [`AmbiguousModelRef`] + /// if the token matches both a provider and a model name. + pub fn resolve( + &self, + registry: &dyn ModelRegistry, + ) -> Result { + match self { + Self::Qualified { provider, model } => Ok(ResolvedModelRef::Model { + provider: Some(provider.clone()), + model: model.clone(), + }), + Self::Bare(token) => { + let is_provider = registry.is_provider(token); + let is_model = registry.is_model(token); + match (is_provider, is_model) { + (true, false) => Ok(ResolvedModelRef::Provider(token.clone())), + (false, true) => Ok(ResolvedModelRef::Model { + provider: registry.provider_of(token), + model: token.clone(), + }), + (true, true) => Err(AmbiguousModelRef { + input: token.clone(), + providers: vec![token.clone()], + models: vec![token.clone()], + }), + // Unknown tokens flow through as bare models — provider TBD at runtime. + (false, false) => Ok(ResolvedModelRef::Model { + provider: None, + model: token.clone(), + }), + } + } + } + } +} + +impl Serialize for ModelRef { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ModelRef { + fn deserialize>(deserializer: D) -> Result { + struct ModelRefVisitor; + + impl Visitor<'_> for ModelRefVisitor { + type Value = ModelRef; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + r#"a model reference such as "openai", "gpt-5.4", or "gemini/gemini-flash""#, + ) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + } + + deserializer.deserialize_str(ModelRefVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestRegistry { + providers: &'static [&'static str], + models: &'static [&'static str], + } + + impl ModelRegistry for TestRegistry { + fn is_provider(&self, token: &str) -> bool { + self.providers.contains(&token) + } + fn is_model(&self, token: &str) -> bool { + self.models.contains(&token) + } + fn provider_of(&self, token: &str) -> Option { + if self.models.contains(&token) { + Some("test".to_owned()) + } else { + None + } + } + } + + #[test] + fn parses_bare_token() { + assert_eq!( + "openai".parse::().unwrap(), + ModelRef::Bare("openai".into()) + ); + } + + #[test] + fn parses_qualified() { + assert_eq!( + "gemini/gemini-flash".parse::().unwrap(), + ModelRef::Qualified { + provider: "gemini".into(), + model: "gemini-flash".into() + } + ); + } + + #[test] + fn rejects_too_many_slashes() { + let err = "a/b/c".parse::().unwrap_err(); + assert!(matches!(err, ParseModelRefError::TooManySlashes { .. })); + } + + #[test] + fn rejects_empty_side() { + assert!(matches!( + "/foo".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + assert!(matches!( + "foo/".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + } + + #[test] + fn rejects_empty_input() { + assert!(matches!( + "".parse::().unwrap_err(), + ParseModelRefError::Empty + )); + } + + #[test] + fn resolves_unique_provider_token() { + let reg = TestRegistry { + providers: &["openai"], + models: &[], + }; + let resolved = ModelRef::Bare("openai".into()).resolve(®).unwrap(); + assert_eq!(resolved, ResolvedModelRef::Provider("openai".into())); + } + + #[test] + fn resolves_unique_model_token() { + let reg = TestRegistry { + providers: &[], + models: &["gpt-5.4"], + }; + let resolved = ModelRef::Bare("gpt-5.4".into()).resolve(®).unwrap(); + assert_eq!( + resolved, + ResolvedModelRef::Model { + provider: Some("test".into()), + model: "gpt-5.4".into() + } + ); + } + + #[test] + fn ambiguous_bare_token_errors() { + let reg = TestRegistry { + providers: &["ambiguous"], + models: &["ambiguous"], + }; + let err = ModelRef::Bare("ambiguous".into()) + .resolve(®) + .unwrap_err(); + assert_eq!(err.input, "ambiguous"); + } + + #[test] + fn qualified_never_ambiguous() { + let reg = TestRegistry { + providers: &["ambiguous"], + models: &["ambiguous"], + }; + let resolved = ModelRef::Qualified { + provider: "a".into(), + model: "b".into(), + } + .resolve(®) + .unwrap(); + assert_eq!( + resolved, + ResolvedModelRef::Model { + provider: Some("a".into()), + model: "b".into(), + } + ); + } + + #[test] + fn display_round_trip() { + for input in ["openai", "gpt-5.4", "gemini/gemini-flash"] { + let parsed: ModelRef = input.parse().unwrap(); + assert_eq!(parsed.to_string(), input); + } + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + m: ModelRef, + } + + let input = r#"{"m":"gemini/gemini-flash"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert!(matches!( + parsed.m, + ModelRef::Qualified { ref provider, ref model } + if provider == "gemini" && model == "gemini-flash" + )); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } +} diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index 9f79df5e8..3355559d2 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -1,19 +1,24 @@ +//! Project domain: first-class project object. +//! +//! `[project]` replaces the old flat `[fabro]` shape. `directory` means the +//! Fabro-managed project directory inside the repo, defaulting to `fabro/`. + +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; -fn default_root() -> String { - ".".to_string() -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct ProjectSettings { - #[serde(default = "default_root")] - pub root: String, -} - -impl Default for ProjectSettings { - fn default() -> Self { - Self { - root: default_root(), - } - } +/// A sparse `[project]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The Fabro-managed project directory inside the repo. Defaults to + /// `fabro/` after layering when unspecified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, } diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index a46ad0b7e..04246fb30 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -1,61 +1,585 @@ +//! Run domain. +//! +//! `[run]` is the shared execution domain. It may appear in all three config +//! files and layer normally. Subdomains cover model selection, git author, +//! prepare steps, execution posture, checkpoint policy, sandbox selection, +//! notifications, interviews, agent knobs, hooks, SCM targeting, pull-request +//! behavior, and artifact collection. + use std::collections::HashMap; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct CheckpointSettings { - #[serde(default)] +use super::duration::Duration; +use super::interp::InterpString; +use super::model_ref::ModelRef; + +/// A sparse `[run]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + /// Flat string-to-string map. Replaces wholesale across layers. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, + /// Run inputs: typed scalar values. Replaces wholesale across layers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inputs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prepare: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub notifications: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interviews: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, +} + +/// The source of a run's goal, either inline literal text or a reference to +/// a file on disk. +/// +/// TOML surface: +/// +/// ```toml +/// # Inline form +/// [run] +/// goal = "Diagnose and fix CI build failures" +/// +/// # File form +/// [run.goal] +/// file = "prompts/fix_build.md" +/// ``` +/// +/// Relative paths inside the `file` variant are resolved against the +/// directory of the config file that declared them at load time (see +/// `fabro_config::resolve_goal_file_paths`). `${env.NAME}` interpolation is +/// supported inside the `file` path; env-tokenized relative paths stay +/// unresolved until consume time and are then resolved against the run's +/// effective working directory. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum RunGoalLayer { + Inline(InterpString), + File { file: InterpString }, +} + +/// Outcome of resolving a [`RunGoalLayer`] to its final goal text. +/// +/// Carries provenance alongside the text so downstream consumers (e.g. the +/// run manifest builder) can distinguish inline goals from file-sourced +/// goals without having to re-walk the layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRunGoal { + pub text: String, + pub source: ResolvedGoalSource, +} + +/// Provenance of a [`ResolvedRunGoal`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedGoalSource { + /// Goal text came from a literal `run.goal = "..."` value. + Inline, + /// Goal text was read from a file on disk. The absolute path of that + /// file is carried for provenance / error reporting. + File { path: std::path::PathBuf }, +} + +/// `[run.model]` — provider-neutral default model selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Ordered list of fallback model references. Supports `...` splice marker + /// at layering time — see [`super::splice_array`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallbacks: Vec, +} + +/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelRefOrSplice { + ModelRef(ModelRef), + Splice, +} + +impl Serialize for ModelRefOrSplice { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::ModelRef(m) => m.serialize(serializer), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for ModelRefOrSplice { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + if raw == super::splice_array::SPLICE_MARKER { + return Ok(Self::Splice); + } + let model = raw.parse::().map_err(D::Error::custom)?; + Ok(Self::ModelRef(model)) + } +} + +/// `[run.git]` — local git behavior such as commit author. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunGitLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitAuthorLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces +/// across layers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunPrepareLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// Optional timeout applied to each prepare step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// A single prepare step. Exactly one of `script` or `command` must be set. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrepareStep { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, +} + +/// `[run.execution]` — run posture knobs. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunExecutionLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval: Option, + /// Positive-form: `true` runs retros, `false` skips them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retros: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunMode { + Normal, + DryRun, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalMode { + Prompt, + Auto, +} + +/// `[run.checkpoint]` — checkpoint policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunCheckpointLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub exclude_globs: Vec, } -fn default_true() -> bool { - true +/// `[run.sandbox]` — sandbox selection and execution-environment surface. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserve: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub devcontainer: Option, + /// Sticky merge-by-key across layers. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct PullRequestSettings { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub draft: bool, - #[serde(default)] - pub auto_merge: bool, - #[serde(default)] - pub merge_strategy: MergeStrategy, +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_mode: Option, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeMode { + Always, + #[default] + Clean, + Dirty, + Never, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaytonaSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_stop_interval: Option, + /// Sticky merge-by-key (provider-native labels). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_clone: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaytonaSnapshotLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dockerfile: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum DaytonaDockerfileLayer { + Inline(String), + Path { path: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum DaytonaNetworkLayer { + Block, + AllowAll, + AllowList { allow_list: Vec }, +} + +/// `[run.notifications.]` — a keyed notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationRouteLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Raw Fabro event names. Splice marker supported at layering time. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, + /// Provider-specific destination subtables. First-pass chat providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// A single string array entry that may be the splice marker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StringOrSplice { + Value(String), + Splice, +} + +impl Serialize for StringOrSplice { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Value(s) => serializer.serialize_str(s), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for StringOrSplice { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == super::splice_array::SPLICE_MARKER { + Ok(Self::Splice) + } else { + Ok(Self::Value(s)) + } + } +} + +/// Provider-specific destination fields for a notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationProviderLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +/// `[run.interviews]` — external interview delivery. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterviewsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterviewProviderLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +/// `[run.agent]` — agent knobs only (permissions, MCPs). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP server entries, keyed by name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentPermissions { + ReadOnly, + ReadWrite, + Full, +} + +/// A single MCP entry. `type` selects the transport; `script`/`command` are +/// mutually exclusive for process-launching transports. Non-launching HTTP +/// transports use neither field. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] +pub enum McpEntryLayer { + Http { + #[serde(default)] + enabled: Option, + url: InterpString, + #[serde(default)] + headers: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Stdio { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Sandbox { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + port: u16, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, +} + +/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or +/// `agent` fields determines the hook behavior. The `id` field, when set, is +/// used for cross-layer replace-by-id merging. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HookEntry { + /// Optional merge identity. Hooks with the same `id` replace in place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Display-only human name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub event: HookEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + // Exactly one of the following groups is expected: + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_rounds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookTlsMode { + #[default] + Verify, + NoVerify, + Off, +} + +/// Reserved marker for hook entries that use the `agent` hook type. Having +/// this as its own field rather than a flag lets `HookEntry` remain a flat +/// struct without a discriminator. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookAgentMarker { + #[default] + Enabled, +} + +#[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, + SandboxReady, + SandboxCleanup, + CheckpointSaved, + PreToolUse, + PostToolUse, + PostToolUseFailure, +} + +/// `[run.scm]` — remote SCM host/provider behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunScmLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + /// Provider-specific SCM leaves. First-pass providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in +/// the first pass; additional branch/checkout context stays on `run` or +/// `run.pull_request` until a concrete use case lands. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScmGitHubLayer; + +/// `[run.pull_request]` — provider-neutral PR behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunPullRequestLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_merge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MergeStrategy { - #[default] Squash, Merge, Rebase, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ArtifactsSettings { - #[serde(default)] +/// `[run.artifacts]` — run artifact collection policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunArtifactsLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub include: Vec, } - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct GitHubSettings { - #[serde(default)] - pub permissions: HashMap, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LlmSettings { - pub model: Option, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct SetupSettings { - #[serde(default)] - pub commands: Vec, - pub timeout_ms: Option, -} diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index b1f006932..c1931c620 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -1,179 +1,323 @@ -use std::path::PathBuf; +//! Server domain. +//! +//! `[server]` is a namespace container; actual settings live in named +//! subdomains (listen, api, web, auth, storage, artifacts, slatedb, +//! scheduler, logging, integrations). Same-host and split-host deployments +//! use the same schema. + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; -fn default_artifact_storage_prefix() -> String { - "artifacts".to_string() +use super::duration::Duration; +use super::interp::InterpString; + +/// A sparse `[server]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slatedb: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrations: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum AuthProvider { - #[default] - Github, - InsecureDisabled, +/// `[server.listen]` — shared bind transport. TLS lives under `[server.listen.tls]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum ServerListenLayer { + Tcp { + #[serde(default)] + address: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct AuthSettings { - #[serde(default)] - pub provider: AuthProvider, - #[serde(default)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerListenTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.api]` — API surface settings. +/// +/// `url` is an optional public URL; it is **not** derived from `server.listen`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[server.web]` — web surface settings. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerWebLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[server.auth]` — cohesive server auth surface. +/// +/// When absent or resolved to no enabled API or web auth configuration, the +/// default server startup posture is fail-closed. Demo and test helpers may +/// explicitly opt in to insecure configurations. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, +} + +/// `[server.auth.api]` — supports multiple strategies concurrently. Each +/// strategy is a named subtable: `[server.auth.api.jwt]`, `[server.auth.api.mtls]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtls: Option, +} + +/// `[server.auth.api.jwt]` — JWT auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiJwtLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, +} + +/// `[server.auth.api.mtls]` — mutual TLS auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiMtlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.auth.web]` — provider-neutral access rules plus keyed providers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allowed_usernames: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub providers: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum ApiAuthStrategy { - Jwt, - Mtls, +/// `[server.auth.web.providers.]` — web auth providers keyed by +/// provider name. First-pass providers cover GitHub. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebProvidersLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct TlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, +/// `[server.auth.web.providers.github]` — GitHub OAuth configuration fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebGithubLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct ApiSettings { - #[serde(default = "default_base_url")] - pub base_url: String, - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, +/// `[server.storage]` — single managed local disk root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerStorageLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, } -fn default_base_url() -> String { - "http://localhost:3000/api/v1".to_string() +/// `[server.artifacts]` — object-store-backed artifact storage. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerArtifactsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, } -impl Default for ApiSettings { - fn default() -> Self { - Self { - base_url: default_base_url(), - authentication_strategies: Vec::new(), - tls: None, - } - } +/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSlateDbLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flush_interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum GitProvider { - #[default] - Github, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitAuthorSettings { - pub name: Option, - pub email: Option, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum WebhookStrategy { - TailscaleFunnel, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebhookSettings { - pub strategy: WebhookStrategy, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitSettings { - #[serde(default)] - pub provider: GitProvider, - pub app_id: Option, - pub client_id: Option, - pub slug: Option, - #[serde(default)] - pub author: GitAuthorSettings, - pub webhooks: Option, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebSettings { - #[serde(default = "default_web_enabled")] - pub enabled: bool, - #[serde(default = "default_web_url")] - pub url: String, - #[serde(default)] - pub auth: AuthSettings, -} - -fn default_web_enabled() -> bool { - true -} - -fn default_web_url() -> String { - "http://localhost:3000".to_string() -} - -impl Default for WebSettings { - fn default() -> Self { - Self { - enabled: default_web_enabled(), - url: default_web_url(), - auth: AuthSettings::default(), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct SlackSettings { - pub default_channel: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct FeaturesSettings { - #[serde(default)] - pub session_sandboxes: bool, - #[serde(default)] - pub retros: bool, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LogSettings { - pub level: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum ArtifactStorageBackend { - #[default] +/// Closed enum of object-store providers. Unknown providers hard-fail +/// against the schema rather than passing through as opaque strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectStoreProvider { Local, S3, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ArtifactStorageSettings { - #[serde(default)] - pub backend: ArtifactStorageBackend, - #[serde(default = "default_artifact_storage_prefix")] - pub prefix: String, +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObjectStoreLocalLayer { + /// Overrides the default root, which otherwise falls back to + /// `server.storage.root`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub bucket: Option, + pub root: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObjectStoreS3Layer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, + pub bucket: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub path_style: Option, } -impl Default for ArtifactStorageSettings { - fn default() -> Self { - Self { - backend: ArtifactStorageBackend::Local, - prefix: default_artifact_storage_prefix(), - bucket: None, - region: None, - endpoint: None, - path_style: None, - } - } +/// `[server.scheduler]` — server-managed execution policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSchedulerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, +} + +/// `[server.logging]` — process-owned logging configuration for the server. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerLoggingLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} + +/// `[server.integrations.]` — cohesive integration surface for chat +/// platforms and git providers (GitHub App, webhooks, etc.). First-pass +/// integrations enumerate known providers rather than using a flatten-HashMap +/// shape so strict unknown-field validation still holds. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerIntegrationsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GithubIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub permissions: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhooks: Option, +} + +/// `[server.integrations.slack]` — Slack workspace credentials and defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlackIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_channel: Option, +} + +/// `[server.integrations.discord]` — Discord workspace configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscordIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `[server.integrations.teams]` — Microsoft Teams configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamsIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IntegrationWebhooksLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookStrategy { + TailscaleFunnel, } diff --git a/lib/crates/fabro-types/src/settings/size.rs b/lib/crates/fabro-types/src/settings/size.rs new file mode 100644 index 000000000..2ac4685e2 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/size.rs @@ -0,0 +1,313 @@ +//! Config-facing byte sizes. +//! +//! Accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`, +//! `GiB`, `TiB`. Decimal units (`KB`, …) are powers of 1000; binary units +//! (`KiB`, …) are powers of 1024. Bare values default to `GB`. Fractional +//! values are not supported in the first pass. The canonical renderer emits +//! the largest decimal unit that represents the value as an integer multiple. + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A byte size parsed from a human-readable string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Size(u64); + +impl Size { + #[must_use] + pub const fn from_bytes(bytes: u64) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(&self) -> u64 { + self.0 + } + + #[must_use] + pub const fn from_gigabytes(gb: u64) -> Self { + Self(gb.saturating_mul(1_000_000_000)) + } +} + +/// An error returned when parsing a size string fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseSizeError { + /// The input was empty or whitespace only. + Empty, + /// The input had a numeric portion that could not be parsed as a non-negative integer. + InvalidNumber { input: String }, + /// The input contained a fractional value, which is not supported in the first pass. + Fractional { input: String }, + /// The input had an unrecognized unit suffix. + InvalidUnit { input: String, unit: String }, + /// The resulting value overflowed a `u64`. + Overflow { input: String }, +} + +impl fmt::Display for ParseSizeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("size value is empty"), + Self::InvalidNumber { input } => { + write!( + f, + "size {input:?}: numeric portion is not a non-negative integer" + ) + } + Self::Fractional { input } => { + write!( + f, + "size {input:?}: fractional values are not supported in the first pass" + ) + } + Self::InvalidUnit { input, unit } => { + write!( + f, + "size {input:?}: unknown unit {unit:?} (expected one of B, KB, MB, GB, TB, KiB, MiB, GiB, TiB)" + ) + } + Self::Overflow { input } => { + write!(f, "size {input:?}: value overflows u64 bytes") + } + } + } +} + +impl std::error::Error for ParseSizeError {} + +impl FromStr for Size { + type Err = ParseSizeError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseSizeError::Empty); + } + + if trimmed.contains('.') { + return Err(ParseSizeError::Fractional { + input: input.to_owned(), + }); + } + + let first_alpha = trimmed.find(|c: char| !c.is_ascii_digit()); + let (num, unit) = match first_alpha { + Some(0) => { + return Err(ParseSizeError::InvalidNumber { + input: input.to_owned(), + }); + } + Some(idx) => (&trimmed[..idx], trimmed[idx..].trim()), + None => (trimmed, ""), + }; + + let n: u64 = num.parse().map_err(|_| ParseSizeError::InvalidNumber { + input: input.to_owned(), + })?; + + // Bare integers default to GB per R84. + let multiplier: u64 = match unit { + "" | "GB" => 1_000_000_000, + "B" => 1, + "KB" => 1_000, + "MB" => 1_000_000, + "TB" => 1_000_000_000_000, + "KiB" => 1_024, + "MiB" => 1_024 * 1_024, + "GiB" => 1_024 * 1_024 * 1_024, + "TiB" => 1_024 * 1_024 * 1_024 * 1_024, + other => { + return Err(ParseSizeError::InvalidUnit { + input: input.to_owned(), + unit: other.to_owned(), + }); + } + }; + + let bytes = n + .checked_mul(multiplier) + .ok_or_else(|| ParseSizeError::Overflow { + input: input.to_owned(), + })?; + + Ok(Self(bytes)) + } +} + +impl fmt::Display for Size { + /// Canonical rendering picks the largest decimal unit that represents the + /// value as an integer multiple, preferring `TB`, `GB`, `MB`, `KB`, `B`. + /// Zero renders as `0B`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + const KB: u64 = 1_000; + const MB: u64 = 1_000_000; + const GB: u64 = 1_000_000_000; + const TB: u64 = 1_000_000_000_000; + + let b = self.0; + if b == 0 { + return f.write_str("0B"); + } + if b.is_multiple_of(TB) { + write!(f, "{}TB", b / TB) + } else if b.is_multiple_of(GB) { + write!(f, "{}GB", b / GB) + } else if b.is_multiple_of(MB) { + write!(f, "{}MB", b / MB) + } else if b.is_multiple_of(KB) { + write!(f, "{}KB", b / KB) + } else { + write!(f, "{b}B") + } + } +} + +impl Serialize for Size { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Size { + fn deserialize>(deserializer: D) -> Result { + struct SizeVisitor; + + impl Visitor<'_> for SizeVisitor { + type Value = Size; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(r#"a size string such as "8GB" or "512MiB", or a bare integer interpreted as GB"#) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + + // Bare integers (non-negative) should parse as GB. + fn visit_u64(self, value: u64) -> Result { + Ok(Size::from_gigabytes(value)) + } + + fn visit_i64(self, value: i64) -> Result { + u64::try_from(value) + .map(Size::from_gigabytes) + .map_err(|_| de::Error::custom("size must be non-negative")) + } + } + + deserializer.deserialize_any(SizeVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_integer_defaults_to_gb() { + assert_eq!("8".parse::().unwrap(), Size::from_gigabytes(8)); + } + + #[test] + fn parses_decimal_units() { + assert_eq!("1B".parse::().unwrap().as_bytes(), 1); + assert_eq!("1KB".parse::().unwrap().as_bytes(), 1_000); + assert_eq!("1MB".parse::().unwrap().as_bytes(), 1_000_000); + assert_eq!("1GB".parse::().unwrap().as_bytes(), 1_000_000_000); + assert_eq!("1TB".parse::().unwrap().as_bytes(), 1_000_000_000_000); + } + + #[test] + fn parses_binary_units() { + assert_eq!("1KiB".parse::().unwrap().as_bytes(), 1_024); + assert_eq!("1MiB".parse::().unwrap().as_bytes(), 1_024 * 1_024); + assert_eq!( + "1GiB".parse::().unwrap().as_bytes(), + 1_024 * 1_024 * 1_024 + ); + assert_eq!( + "1TiB".parse::().unwrap().as_bytes(), + 1_024u64 * 1_024 * 1_024 * 1_024 + ); + } + + #[test] + fn rejects_fractional_values() { + let err = "1.5GB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Fractional { .. })); + } + + #[test] + fn rejects_unknown_units() { + let err = "5XB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::InvalidUnit { unit, .. } if unit == "XB")); + } + + #[test] + fn rejects_empty_input() { + let err = "".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Empty)); + } + + #[test] + fn canonical_render_uses_largest_decimal_unit() { + assert_eq!(Size::from_bytes(1_000_000_000_000).to_string(), "1TB"); + assert_eq!(Size::from_bytes(2_000_000_000).to_string(), "2GB"); + assert_eq!(Size::from_bytes(5_000_000).to_string(), "5MB"); + assert_eq!(Size::from_bytes(3_000).to_string(), "3KB"); + assert_eq!(Size::from_bytes(0).to_string(), "0B"); + } + + #[test] + fn canonical_render_falls_back_to_bytes_for_odd_values() { + // 1_500 bytes is not a whole MB/KB. 1_500 / 1_000 = 1.5 → falls back to B. + assert_eq!(Size::from_bytes(1_500).to_string(), "1500B"); + } + + #[test] + fn binary_unit_values_render_as_bytes_when_not_a_whole_decimal_unit() { + // 1 KiB = 1024 bytes — not divisible by 1000, so canonical render is bytes. + let size = "1KiB".parse::().unwrap(); + assert_eq!(size.to_string(), "1024B"); + } + + #[test] + fn overflow_detected() { + let err = format!("{}TB", u64::MAX).parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Overflow { .. })); + } + + #[test] + fn serde_round_trip_via_json_string() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + s: Size, + } + + let input = r#"{"s":"8GB"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s, Size::from_gigabytes(8)); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, r#"{"s":"8GB"}"#); + } + + #[test] + fn serde_accepts_bare_integer_as_gb() { + #[derive(Debug, serde::Deserialize, PartialEq)] + struct Wrap { + s: Size, + } + + let input = r#"{"s":8}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s, Size::from_gigabytes(8)); + } +} diff --git a/lib/crates/fabro-types/src/settings/splice_array.rs b/lib/crates/fabro-types/src/settings/splice_array.rs new file mode 100644 index 000000000..435570d67 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/splice_array.rs @@ -0,0 +1,261 @@ +//! Splice-capable string arrays. +//! +//! In declared splice-capable array paths, the literal string value `"..."` +//! is reserved: it represents "splice in inherited values from lower-precedence +//! layers here." At most one `"..."` marker may appear per array. In the base +//! layer with no inherited parent, the marker resolves to an empty inherited +//! segment. In non-splice paths the same literal is a hard error — enforced +//! by using the plain `Vec` type elsewhere and this type only where +//! splice semantics are explicitly allowed. + +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The reserved literal that marks the splice insertion point. +pub const SPLICE_MARKER: &str = "..."; + +/// A string array that may contain at most one splice marker. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SpliceArray { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Entry { + Value(String), + Splice, +} + +/// An error returned when a splice array fails validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpliceArrayError { + /// The array contained more than one splice marker. + MultipleMarkers, +} + +impl fmt::Display for SpliceArrayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MultipleMarkers => { + f.write_str(r#"splice array must contain at most one "..." marker"#) + } + } + } +} + +impl std::error::Error for SpliceArrayError {} + +impl SpliceArray { + /// Build a splice array from a raw `Vec`. + pub fn from_raw(raw: Vec) -> Result { + let mut entries = Vec::with_capacity(raw.len()); + let mut marker_count = 0; + for item in raw { + if item == SPLICE_MARKER { + marker_count += 1; + entries.push(Entry::Splice); + } else { + entries.push(Entry::Value(item)); + } + } + if marker_count > 1 { + return Err(SpliceArrayError::MultipleMarkers); + } + Ok(Self { entries }) + } + + /// Build a splice array with no inherited splice marker. + #[must_use] + pub fn from_values(values: impl IntoIterator) -> Self { + Self { + entries: values.into_iter().map(Entry::Value).collect(), + } + } + + /// True when the array contains a splice marker. + #[must_use] + pub fn has_splice(&self) -> bool { + self.entries.iter().any(|e| matches!(e, Entry::Splice)) + } + + /// The index of the splice marker, if present. + #[must_use] + pub fn splice_position(&self) -> Option { + self.entries.iter().position(|e| matches!(e, Entry::Splice)) + } + + /// The non-splice values, in source order. + #[must_use] + pub fn values(&self) -> Vec<&str> { + self.entries + .iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v.as_str()), + Entry::Splice => None, + }) + .collect() + } + + /// Resolve this array against an inherited lower-precedence value list. + /// + /// - If the array has a splice marker, the inherited list is spliced in + /// at the marker position. + /// - If the array has no splice marker, it replaces the inherited list + /// wholesale. + #[must_use] + pub fn resolve(self, inherited: Vec) -> Vec { + let Some(pos) = self.splice_position() else { + return self + .entries + .into_iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v), + Entry::Splice => None, + }) + .collect(); + }; + + let mut prefix = Vec::new(); + let mut suffix = Vec::new(); + for (i, entry) in self.entries.into_iter().enumerate() { + match entry { + Entry::Value(v) => { + if i < pos { + prefix.push(v); + } else { + suffix.push(v); + } + } + Entry::Splice => {} + } + } + + let mut out = prefix; + out.extend(inherited); + out.extend(suffix); + out + } +} + +impl Serialize for SpliceArray { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.entries.len()))?; + for entry in &self.entries { + match entry { + Entry::Value(v) => seq.serialize_element(v)?, + Entry::Splice => seq.serialize_element(SPLICE_MARKER)?, + } + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for SpliceArray { + fn deserialize>(deserializer: D) -> Result { + struct SpliceArrayVisitor; + + impl<'de> Visitor<'de> for SpliceArrayVisitor { + type Value = SpliceArray; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + r#"an array of strings, optionally containing a single "..." splice marker"#, + ) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut raw: Vec = Vec::new(); + while let Some(item) = seq.next_element::()? { + raw.push(item); + } + SpliceArray::from_raw(raw).map_err(de::Error::custom) + } + } + + deserializer.deserialize_seq(SpliceArrayVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_raw_with_no_marker() { + let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap(); + assert!(!arr.has_splice()); + assert_eq!(arr.values(), vec!["a", "b"]); + } + + #[test] + fn append_marker_at_front() { + let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(0)); + let resolved = arr.resolve(vec!["a".into(), "b".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn prepend_marker_at_back() { + let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(1)); + let resolved = arr.resolve(vec!["b".into(), "c".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn marker_in_middle() { + let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap(); + let resolved = arr.resolve(vec!["mid".into()]); + assert_eq!(resolved, vec!["pre", "mid", "post"]); + } + + #[test] + fn replace_semantics_without_marker() { + let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap(); + let resolved = arr.resolve(vec!["inherited".into()]); + assert_eq!(resolved, vec!["only"]); + } + + #[test] + fn multiple_markers_rejected() { + let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err(); + assert_eq!(err, SpliceArrayError::MultipleMarkers); + } + + #[test] + fn base_layer_with_splice_resolves_to_empty_inherited() { + let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap(); + let resolved = arr.resolve(vec![]); + assert_eq!(resolved, vec!["b"]); + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + a: SpliceArray, + } + + let input = r#"{"a":["...","b"]}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert!(parsed.a.has_splice()); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } + + #[test] + fn serde_rejects_multiple_markers() { + #[derive(Debug, serde::Deserialize)] + struct Wrap { + _a: SpliceArray, + } + + let input = r#"{"_a":["...","..."]}"#; + let err = serde_json::from_str::(input).unwrap_err(); + assert!(err.to_string().contains("at most one")); + } +} diff --git a/lib/crates/fabro-types/src/settings/tree.rs b/lib/crates/fabro-types/src/settings/tree.rs new file mode 100644 index 000000000..498151d00 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/tree.rs @@ -0,0 +1,422 @@ +//! The top-level v2 sparse parse tree. +//! +//! This struct models a single settings file (`~/.fabro/settings.toml`, +//! `fabro.toml`, or `workflow.toml`) after parsing. Fields unset in the source +//! stay `None`/empty. Strict unknown-key handling catches any top-level key +//! that is not one of the reserved domains, with targeted rename hints for +//! legacy flat shapes. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::cli::CliLayer; +use super::features::FeaturesLayer; +use super::project::ProjectLayer; +use super::run::RunLayer; +use super::server::ServerLayer; +use super::workflow::WorkflowLayer; + +/// A parsed settings file before layering. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SettingsFile { + #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, +} + +/// A top-level key in a v2 settings file. Anything not in this list is rejected +/// at parse time with a targeted rename hint when possible. +const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[ + "_version", "project", "workflow", "run", "cli", "server", "features", +]; + +/// An error returned when a settings file fails parse-level validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + /// A low-level TOML parse error. + Toml(String), + /// Schema version pre-validation failed. + Version(super::version::VersionError), + /// A top-level key is not part of the v2 schema. Rename hints are + /// populated for known-legacy keys. + UnknownTopLevelKey { key: String, hint: Option }, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"), + Self::Version(err) => fmt::Display::fmt(err, f), + Self::UnknownTopLevelKey { key, hint } => { + if let Some(hint) = hint { + write!(f, "unknown top-level settings key `{key}`: {hint}") + } else { + write!( + f, + "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`" + ) + } + } + } + } +} + +impl std::error::Error for ParseError {} + +/// Parse a v2 settings file from TOML text. +/// +/// This runs `_version` pre-validation, top-level unknown-key validation +/// with rename hints, and then decodes the sparse namespaced tree. Deeper +/// unknown-key validation for nested tables is enforced by the individual +/// layer types via `#[serde(deny_unknown_fields)]`. +pub fn parse_settings_file(input: &str) -> Result { + let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?; + super::version::validate_version(&raw).map_err(ParseError::Version)?; + + if let Some(table) = raw.as_table() { + for key in table.keys() { + if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) { + return Err(ParseError::UnknownTopLevelKey { + key: key.clone(), + hint: rename_hint(key), + }); + } + } + } + + let file: SettingsFile = raw + .try_into::() + .map_err(|e| ParseError::Toml(e.to_string()))?; + Ok(file) +} + +/// Targeted rename hint for known legacy top-level keys. +fn rename_hint(key: &str) -> Option { + let target = match key { + "version" => "rename to `_version`", + "goal" | "goal_file" | "work_dir" | "directory" => "move to `[run]`", + "graph" => "move to `[workflow]`", + "labels" => "move to `[run.metadata]`", + "llm" => "rename to `[run.model]`", + "vars" => "rename to `[run.inputs]`", + "setup" => "rename to `[run.prepare]`", + "sandbox" => "move under `[run.sandbox]`", + "checkpoint" => "move under `[run.checkpoint]`", + "pull_request" => "move under `[run.pull_request]`", + "artifacts" => "move under `[run.artifacts]`", + "hooks" => "move under `[[run.hooks]]`", + "mcp_servers" => "move under `[run.agent.mcps.]` or `[cli.exec.agent.mcps.]`", + "exec" => "rename to `[cli.exec]`", + "api" => "rename to `[server.api]`", + "web" => "rename to `[server.web]`", + "artifact_storage" => "rename to `[server.artifacts]`", + "storage_dir" | "data_dir" => "rename to `[server.storage] root`", + "max_concurrent_runs" => "rename to `[server.scheduler]` field", + "fabro" => "rename to `[project]`; `fabro.root` becomes `project.directory`", + "git" => "split into `[run.git]` (local git behavior) and `[server.integrations.github]`", + "github" => "rename to `[server.integrations.github]`", + "slack" => "move under `[server.integrations.slack]`", + "log" => "rename to `[server.logging]` or `[cli.logging]` depending on owner", + "prevent_idle_sleep" => "rename to `[cli.exec] prevent_idle_sleep`", + "verbose" => "rename to `[cli.output] verbosity`", + "upgrade_check" => "rename to `[cli.updates] check`", + "dry_run" => "rename to `[run.execution] mode = \"dry_run\"`", + "auto_approve" => "rename to `[run.execution] approval = \"auto\"`", + "no_retro" => "rename to `[run.execution] retros = false`", + _ => return None, + }; + Some(target.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_empty_file() { + let file = parse_settings_file("").unwrap(); + assert_eq!(file, SettingsFile::default()); + } + + #[test] + fn parses_minimal_valid_file() { + let input = r#" +_version = 1 + +[project] +name = "Fabro" +"#; + let file = parse_settings_file(input).unwrap(); + assert_eq!(file.version, Some(1)); + assert!(file.project.is_some()); + } + + #[test] + fn rejects_legacy_version_key_with_rename_hint() { + let err = parse_settings_file("version = 1").unwrap_err(); + assert!(matches!(err, ParseError::Version(_))); + assert!(err.to_string().contains("_version")); + } + + #[test] + fn rejects_unknown_top_level_key() { + let err = parse_settings_file("unknown_key = 1").unwrap_err(); + let ParseError::UnknownTopLevelKey { key, .. } = err else { + panic!("expected UnknownTopLevelKey, got: {err:?}"); + }; + assert_eq!(key, "unknown_key"); + } + + #[test] + fn legacy_llm_section_gets_run_model_rename_hint() { + let err = parse_settings_file("[llm]\nprovider = \"openai\"").unwrap_err(); + assert!( + err.to_string().contains("run.model"), + "expected rename hint for [llm]: {err}" + ); + } + + #[test] + fn legacy_vars_section_gets_run_inputs_rename_hint() { + let err = parse_settings_file("[vars]\nk = \"v\"").unwrap_err(); + assert!( + err.to_string().contains("run.inputs"), + "expected rename hint for [vars]: {err}" + ); + } + + #[test] + fn legacy_exec_section_gets_cli_exec_rename_hint() { + let err = parse_settings_file("[exec]\nmodel = \"claude-opus\"").unwrap_err(); + assert!( + err.to_string().contains("cli.exec"), + "expected rename hint for [exec]: {err}" + ); + } + + #[test] + fn legacy_fabro_section_gets_project_rename_hint() { + let err = parse_settings_file("[fabro]\nroot = \"fabro/\"").unwrap_err(); + assert!( + err.to_string().contains("project"), + "expected rename hint for [fabro]: {err}" + ); + } + + #[test] + fn higher_version_rejected_with_upgrade_hint() { + let err = parse_settings_file("_version = 99").unwrap_err(); + assert!(err.to_string().contains("Upgrade")); + } + + #[test] + fn representative_full_tree_parses() { + let input = r##" +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[project.metadata] +owner = "platform" + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" +working_dir = "/workspace" + +[run.inputs] +repo = "fabro" +branch = "main" + +[run.metadata] +team = "auth" + +[run.model] +provider = "anthropic" +name = "sonnet" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] + +[run.git.author] +name = "fabro-bot" +email = "bot@fabro.sh" + +[[run.prepare.steps]] +script = "bun install" + +[[run.prepare.steps]] +command = ["bun", "run", "typecheck"] + +[run.execution] +mode = "normal" +approval = "prompt" +retros = true + +[run.checkpoint] +exclude_globs = ["target/", "node_modules/"] + +[run.sandbox] +provider = "daytona" +preserve = false + +[run.sandbox.env] +AWS_REGION = "us-west-2" + +[run.sandbox.daytona] +auto_stop_interval = 60 + +[run.sandbox.daytona.snapshot] +name = "fabro-dev" +cpu = 4 +memory = "8GB" +disk = "20GB" + +[run.agent] +permissions = "read-write" + +[run.agent.mcps.fs] +type = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem"] + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed", "run.completed"] + +[run.notifications.ops.slack] +channel = "#ops" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" + +[[run.hooks]] +id = "pre-commit" +name = "Run linter before each commit" +event = "pre_tool_use" +script = "bun run lint" + +[run.pull_request] +enabled = true +draft = true +auto_merge = false +merge_strategy = "squash" + +[run.artifacts] +include = ["target/debug/fabro"] + +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" + +[cli.auth] +strategy = "mtls" + +[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 + +[cli.logging] +level = "info" + +[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.artifacts.s3] +bucket = "fabro-artifacts" +region = "us-west-2" + +[server.slatedb] +provider = "s3" +prefix = "runs" +flush_interval = "1s" + +[server.slatedb.s3] +bucket = "fabro-slatedb" +region = "us-west-2" + +[server.scheduler] +max_concurrent_runs = 10 + +[server.logging] +level = "info" + +[features] +session_sandboxes = true +"##; + + let file = parse_settings_file(input).expect("full fixture should parse"); + let project = file.project.expect("project present"); + assert_eq!(project.name.as_deref(), Some("Fabro")); + assert_eq!(project.directory.as_deref(), Some("fabro/")); + + let run = file.run.expect("run present"); + let model = run.model.expect("run.model present"); + assert_eq!(model.fallbacks.len(), 3); + + let sandbox = run.sandbox.expect("run.sandbox present"); + assert_eq!(sandbox.env.len(), 1); + let daytona = sandbox.daytona.expect("daytona leaf present"); + let snap = daytona.snapshot.expect("daytona snapshot present"); + assert_eq!(snap.memory.map(|s| s.as_bytes()), Some(8_000_000_000)); + + let hooks = run.hooks; + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].id.as_deref(), Some("pre-commit")); + + let cli = file.cli.expect("cli present"); + assert!(cli.target.is_some()); + assert!(cli.exec.is_some()); + + let server = file.server.expect("server present"); + let slate = server.slatedb.expect("slatedb present"); + assert!(slate.flush_interval.is_some()); + } +} diff --git a/lib/crates/fabro-types/src/settings/user.rs b/lib/crates/fabro-types/src/settings/user.rs deleted file mode 100644 index 1818e124a..000000000 --- a/lib/crates/fabro-types/src/settings/user.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "kebab-case")] -pub enum OutputFormat { - Text, - Json, -} - -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "kebab-case")] -pub enum PermissionLevel { - ReadOnly, - ReadWrite, - Full, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct ClientTlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ServerSettings { - pub target: Option, - pub tls: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExecSettings { - pub provider: Option, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} diff --git a/lib/crates/fabro-types/src/settings/version.rs b/lib/crates/fabro-types/src/settings/version.rs new file mode 100644 index 000000000..bb3dc8e15 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/version.rs @@ -0,0 +1,111 @@ +//! Schema version handling. +//! +//! The settings schema version lives under the reserved top-level key +//! `_version`. Missing defaults to `1`. The legacy top-level `version` key is +//! a targeted rename hint. Unsupported higher versions hard-fail with an +//! upgrade hint before deeper validation continues. + +use std::fmt; + +/// The highest schema version this parser can consume. +pub const CURRENT_VERSION: u32 = 1; + +/// An error returned when `_version` pre-validation fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionError { + /// The file contains a legacy top-level `version` key. Offer a rename hint. + LegacyVersionKey, + /// The file declares `_version` higher than [`CURRENT_VERSION`]. + UnsupportedHigherVersion { found: u32 }, +} + +impl fmt::Display for VersionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LegacyVersionKey => f.write_str( + "settings files must use `_version` instead of `version`. Rename the key and try again.", + ), + Self::UnsupportedHigherVersion { found } => write!( + f, + "settings schema version {found} is newer than this build supports (current: {CURRENT_VERSION}). Upgrade Fabro to read this file." + ), + } + } +} + +impl std::error::Error for VersionError {} + +/// The parsed schema version for a settings file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaVersion(pub u32); + +impl Default for SchemaVersion { + fn default() -> Self { + Self(CURRENT_VERSION) + } +} + +/// Validate and extract the schema version from a parsed TOML value before +/// deeper validation continues. +/// +/// This function peeks at the top-level table and enforces three rules: +/// +/// 1. `version = ...` (no underscore) is an explicit rename hint error. +/// 2. `_version` higher than [`CURRENT_VERSION`] is an upgrade hint error. +/// 3. Missing `_version` defaults to [`CURRENT_VERSION`]. +pub fn validate_version(raw: &toml::Value) -> Result { + if let Some(table) = raw.as_table() { + if table.contains_key("version") { + return Err(VersionError::LegacyVersionKey); + } + if let Some(value) = table.get("_version") { + if let Some(n) = value.as_integer() { + let found = u32::try_from(n).unwrap_or(u32::MAX); + if found > CURRENT_VERSION { + return Err(VersionError::UnsupportedHigherVersion { found }); + } + return Ok(SchemaVersion(found)); + } + } + } + Ok(SchemaVersion::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(input: &str) -> toml::Value { + toml::from_str(input).expect("toml parse") + } + + #[test] + fn missing_version_defaults_to_current() { + let raw = parse(""); + let v = validate_version(&raw).unwrap(); + assert_eq!(v, SchemaVersion(CURRENT_VERSION)); + } + + #[test] + fn explicit_version_one_is_accepted() { + let raw = parse("_version = 1"); + let v = validate_version(&raw).unwrap(); + assert_eq!(v, SchemaVersion(1)); + } + + #[test] + fn legacy_version_key_errors_with_rename_hint() { + let raw = parse("version = 1"); + let err = validate_version(&raw).unwrap_err(); + assert_eq!(err, VersionError::LegacyVersionKey); + assert!(err.to_string().contains("_version")); + } + + #[test] + fn unsupported_higher_version_errors_with_upgrade_hint() { + let raw = parse("_version = 99"); + let err = validate_version(&raw).unwrap_err(); + assert_eq!(err, VersionError::UnsupportedHigherVersion { found: 99 }); + assert!(err.to_string().contains("Upgrade")); + } +} diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs new file mode 100644 index 000000000..8b0e986fd --- /dev/null +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -0,0 +1,23 @@ +//! Workflow domain. +//! +//! `[workflow]` is descriptive: `name`, `description`, optional `graph` (a +//! path override for the default `workflow.fabro` file), and `metadata`. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A sparse `[workflow]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional override for the default `workflow.fabro` graph path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} diff --git a/lib/crates/fabro-types/src/stage_id.rs b/lib/crates/fabro-types/src/stage_id.rs index 846d61cb8..eef135774 100644 --- a/lib/crates/fabro-types/src/stage_id.rs +++ b/lib/crates/fabro-types/src/stage_id.rs @@ -76,7 +76,7 @@ impl Serialize for StageId { where S: Serializer, { - serializer.serialize_str(&self.to_string()) + serializer.collect_str(self) } } @@ -157,7 +157,7 @@ impl Serialize for ParallelBranchId { where S: Serializer, { - serializer.serialize_str(&self.to_string()) + serializer.collect_str(self) } } diff --git a/lib/crates/fabro-workflow/src/config.rs b/lib/crates/fabro-workflow/src/config.rs new file mode 100644 index 000000000..c11478d16 --- /dev/null +++ b/lib/crates/fabro-workflow/src/config.rs @@ -0,0 +1,71 @@ +//! Workflow runtime configuration shapes. +//! +//! Runtime-side types consumed by the pipeline. The v2 parse tree lives in +//! `fabro_types::settings::run::{RunPullRequestLayer, MergeStrategy, +//! RunArtifactsLayer}`. Conversion from v2 lives in [`bridge_pull_request`] +//! / [`bridge_run_artifacts`]. + +use fabro_types::settings::run::{ + MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, +}; +use serde::{Deserialize, Serialize}; + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PullRequestSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub draft: bool, + #[serde(default)] + pub auto_merge: bool, + #[serde(default)] + pub merge_strategy: MergeStrategy, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MergeStrategy { + #[default] + Squash, + Merge, + Rebase, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ArtifactsSettings { + #[serde(default)] + pub include: Vec, +} + +#[must_use] +pub fn bridge_merge_strategy(m: V2MergeStrategy) -> MergeStrategy { + match m { + V2MergeStrategy::Squash => MergeStrategy::Squash, + V2MergeStrategy::Merge => MergeStrategy::Merge, + V2MergeStrategy::Rebase => MergeStrategy::Rebase, + } +} + +#[must_use] +pub fn bridge_pull_request(pr: &RunPullRequestLayer) -> PullRequestSettings { + PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + } +} + +#[must_use] +pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings { + ArtifactsSettings { + include: artifacts.include.clone(), + } +} diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index c5eb56052..7e56abb92 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,8 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::run_event as fabro_types; use ::fabro_types::{ - ActorKind, ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, - RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, + ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, RunEvent, RunId, + RunProvenance, StageId, StageStatus, StatusReason, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -1141,6 +1141,7 @@ impl Event { } } +#[must_use] pub fn event_name(event: &Event) -> &'static str { match event { Event::RunCreated { .. } => "run.created", @@ -1453,11 +1454,10 @@ fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { fn agent_actor_for_event(event: &AgentEvent, session_id: Option<&str>) -> Option { match event { - AgentEvent::AssistantMessage { model, .. } => Some(ActorRef { - kind: ActorKind::Agent, - id: session_id.map(str::to_string), - display: Some(model.clone()), - }), + AgentEvent::AssistantMessage { model, .. } => Some(ActorRef::agent( + session_id.map(str::to_string), + Some(model.clone()), + )), _ => None, } } @@ -2487,7 +2487,7 @@ impl StageScope { } /// Build scope for a handler invocation. Prefers the `current_stage_scope` - /// seeded by the fidelity lifecycle before_attempt hook, and falls back to + /// seeded by the fidelity lifecycle `before_node` hook, and falls back to /// synthesizing one from `node_id` for direct-handler call sites (tests, /// etc.) that don't go through the full lifecycle. pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { @@ -2495,12 +2495,38 @@ impl StageScope { .current_stage_scope() .unwrap_or_else(|| Self::from_context(context, node_id)) } + + /// Build scope for the branch-lifecycle events emitted by the parallel + /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the + /// pre-dispatch `GitCommit` for the branch worktree). + /// + /// `target_visit` is the visit count of `target_node_id` for this + /// particular branch dispatch. The parallel handler currently passes + /// `1` because branches haven't been re-entered yet at the point of + /// scope construction; a future change that loops a parallel node + /// must pass the actual visit so envelope `stage_id`s stay accurate. + #[must_use] + pub fn for_parallel_branch( + target_node_id: impl Into, + target_visit: u32, + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, + ) -> Self { + Self { + node_id: target_node_id.into(), + visit: target_visit, + parallel_group_id: Some(parallel_group_id), + parallel_branch_id: Some(parallel_branch_id), + } + } } +#[must_use] pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { to_run_event_at(run_id, event, Utc::now(), None) } +#[must_use] pub fn to_run_event_at( run_id: &RunId, event: &Event, @@ -2846,6 +2872,7 @@ impl Emitter { #[cfg(test)] mod tests { use super::*; + use ::fabro_types::ActorKind; use ::fabro_types::fixtures; use std::sync::{Arc, Mutex}; @@ -3394,7 +3421,8 @@ mod tests { #[test] fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::{Graph, RunAuthMethod, RunSubjectProvenance, Settings, fixtures}; + use ::fabro_types::settings::SettingsFile; + use ::fabro_types::{Graph, RunAuthMethod, RunSubjectProvenance, fixtures}; let provenance = RunProvenance { server: None, @@ -3409,7 +3437,7 @@ mod tests { &fixtures::RUN_1, &Event::RunCreated { run_id: fixtures::RUN_1, - settings: serde_json::to_value(Settings::default()).unwrap(), + settings: serde_json::to_value(SettingsFile::default()).unwrap(), graph: serde_json::to_value(Graph::new("test")).unwrap(), workflow_source: None, workflow_config: None, diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 24ccf9fac..153a38228 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::process::Command; use fabro_checkpoint::git::Store; -use fabro_types::Settings; +use fabro_types::settings::SettingsFile; use crate::error::{FabroError, Result}; use tokio::task::{JoinError, spawn_blocking}; @@ -15,9 +15,9 @@ pub use fabro_checkpoint::metadata::MetadataStore; /// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`). pub const RUN_BRANCH_PREFIX: &str = "fabro/run/"; -pub fn git_author_from_settings(settings: &Settings) -> GitAuthor { +pub fn git_author_from_settings(settings: &SettingsFile) -> GitAuthor { settings - .git_author() + .run_git_author() .map(GitAuthor::from) .unwrap_or_default() } diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 541c2d226..484f65b5e 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -14,7 +14,6 @@ use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{ BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus, }; -use crate::run_dir::visit_from_context; use crate::transforms::variable_expansion::expand_vars; use fabro_graphviz::graph::{Graph, Node}; @@ -250,7 +249,6 @@ impl Handler for AgentHandler { format!("{preamble}\n\n{expanded}") }; - let visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); let prompt_provider = node .provider() .map(String::from) @@ -260,7 +258,7 @@ impl Handler for AgentHandler { services.emitter.emit_scoped( &Event::Prompt { stage: node.id.clone(), - visit, + visit: stage_scope.visit, text: prompt.clone(), mode: Some("agent".to_string()), provider: prompt_provider, @@ -720,8 +718,7 @@ mod tests { emitter.emit_scoped( &crate::event::Event::Agent { stage: node.id.clone(), - visit: u32::try_from(crate::run_dir::visit_from_context(context)) - .unwrap_or(u32::MAX), + visit: scope.visit, event: fabro_agent::AgentEvent::SessionStarted { provider: Some("openai".to_string()), model: Some("gpt-5.4".to_string()), diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs index 458ff4886..0c1cc67dc 100644 --- a/lib/crates/fabro-workflow/src/handler/fan_in.rs +++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs @@ -6,7 +6,6 @@ use crate::context::keys; use crate::error::FabroError; use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{Outcome, OutcomeExt}; -use crate::run_dir::visit_from_context; use crate::sandbox_git::git_merge_ff_only; use async_trait::async_trait; use fabro_agent::Sandbox; @@ -231,13 +230,12 @@ async fn llm_evaluate( Respond with the ID of the best candidate." ); - let visit_u32 = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); let stage_scope = StageScope::for_handler(context, node_id); emitter.emit_scoped( &Event::Prompt { stage: node_id.to_string(), - visit: visit_u32, + visit: stage_scope.visit, text: full_prompt.clone(), mode: Some("fan_in".to_string()), provider: None, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 3e03af58d..9b13ac08c 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -454,13 +454,13 @@ impl CodergenBackend for AgentApiBackend { touched: HashSet::new(), last: None, })); - let event_scope = StageScope::for_handler(context, &node.id); + let stage_scope = StageScope::for_handler(context, &node.id); // Subscribe to session events: forward to pipeline emitter + track files. spawn_event_forwarder( &session, node.id.clone(), - event_scope.clone(), + stage_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); @@ -497,7 +497,7 @@ impl CodergenBackend for AgentApiBackend { to_model: target.model.clone(), error: error_msg.clone(), }, - &event_scope, + &stage_scope, ); let target_provider: Provider = match target.provider.parse() { @@ -528,7 +528,7 @@ impl CodergenBackend for AgentApiBackend { spawn_event_forwarder( &session, node.id.clone(), - event_scope.clone(), + stage_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 9e52d8c63..08e2b04f8 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -12,7 +12,6 @@ use crate::context::Context; use crate::error::FabroError; use crate::event::{Emitter, Event, StageScope}; use crate::outcome::billed_model_usage_from_llm; -use crate::run_dir::visit_from_context; use fabro_graphviz::graph::Node; use fabro_llm::types::TokenCounts; @@ -55,10 +54,6 @@ impl AgentCli { } } -fn current_visit(context: &Context) -> u32 { - u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX) -} - /// Ensure the CLI tool for the given provider is installed in the sandbox. /// /// Checks if the CLI binary exists; if not, installs Node.js (if missing) and @@ -461,7 +456,7 @@ impl CodergenBackend for AgentCliBackend { &self, node: &Node, prompt: &str, - _context: &Context, + context: &Context, _thread_id: Option<&str>, emitter: &Arc, sandbox: &Arc, @@ -496,11 +491,11 @@ impl CodergenBackend for AgentCliBackend { ensure_cli(cli, provider, sandbox, emitter).await?; let command = cli_command_for_provider(provider, model, &prompt_path); - let stage_scope = StageScope::for_handler(_context, &node.id); + let stage_scope = StageScope::for_handler(context, &node.id); emitter.emit_scoped( &Event::AgentCliStarted { node_id: node.id.clone(), - visit: current_visit(_context), + visit: stage_scope.visit, mode: "cli".to_string(), provider: provider.as_str().to_string(), model: model.to_string(), diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index f8340c4ae..43cb95b74 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -18,7 +18,7 @@ use crate::run_options::RunOptions; use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::Settings; +use fabro_types::settings::SettingsFile; use object_store::memory::InMemory; use tokio::time::{sleep, timeout}; @@ -74,7 +74,7 @@ fn parse_child_graph( source: dot.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: cwd.clone(), custom_transforms: Vec::new(), })?; @@ -116,7 +116,7 @@ fn parse_child_graph( }; let validated = validate(ValidateInput { workflow, - settings: Settings::default(), + settings: SettingsFile::default(), cwd, custom_transforms: Vec::new(), })?; @@ -202,7 +202,7 @@ impl Handler for SubWorkflowHandler { let child_cancel = Arc::clone(&cancel_token); let child_run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: child_logs, cancel_token: Some(cancel_token), // Child workflows are part of the parent run's event stream. diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index acbb8fc7c..de674fe9a 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -151,15 +151,18 @@ impl Handler for ParallelHandler { .unwrap_or("wait_all"), ); - let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); - let parallel_group_id = StageId::new(node.id.clone(), parallel_visit); + let parallel_stage_scope = StageScope::for_handler(context, &node.id); + let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit); - services.emitter.emit(&Event::ParallelStarted { - node_id: node.id.clone(), - visit: parallel_visit, - branch_count: branches.len(), - join_policy: join_policy.to_string(), - }); + services.emitter.emit_scoped( + &Event::ParallelStarted { + node_id: node.id.clone(), + visit: parallel_stage_scope.visit, + branch_count: branches.len(), + join_policy: join_policy.to_string(), + }, + ¶llel_stage_scope, + ); { let run_id = context .run_id() @@ -301,12 +304,12 @@ impl Handler for ParallelHandler { .map(|gs| gs.git_author.clone()) .unwrap_or_default(); let group_id = parallel_group_id.clone(); - let branch_scope = StageScope { - node_id: setup.target_id.clone(), - visit: 1, - parallel_group_id: Some(group_id.clone()), - parallel_branch_id: Some(setup.parallel_branch_id.clone()), - }; + let branch_scope = StageScope::for_parallel_branch( + setup.target_id.clone(), + 1, + group_id.clone(), + setup.parallel_branch_id.clone(), + ); let handle = tokio::spawn(async move { let _permit = sem @@ -526,14 +529,17 @@ impl Handler for ParallelHandler { context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json)); context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total)); - services.emitter.emit(&Event::ParallelCompleted { - node_id: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), - duration_ms: millis_u64(parallel_start.elapsed()), - success_count, - failure_count: fail_count, - results: results_json.clone(), - }); + services.emitter.emit_scoped( + &Event::ParallelCompleted { + node_id: node.id.clone(), + visit: parallel_stage_scope.visit, + duration_ms: millis_u64(parallel_start.elapsed()), + success_count, + failure_count: fail_count, + results: results_json.clone(), + }, + ¶llel_stage_scope, + ); { let run_id = context .run_id() diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 9dc5e25c2..67fc8227c 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -5,7 +5,6 @@ use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; use crate::event::{Event, StageScope}; use crate::outcome::Outcome; -use crate::run_dir::visit_from_context; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; use fabro_model::Provider; @@ -60,7 +59,6 @@ impl Handler for PromptHandler { } else { format!("{preamble}\n\n{expanded}") }; - let _visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); // 1b. Discover project docs for system prompt when project_memory is enabled let system_prompt = if node.project_memory() { @@ -95,7 +93,7 @@ impl Handler for PromptHandler { services.emitter.emit_scoped( &Event::Prompt { stage: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + visit: stage_scope.visit, text: prompt.clone(), mode: Some("prompt".to_string()), provider: prompt_provider.clone(), diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 76e2311a1..270ce6d9d 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -116,6 +116,7 @@ pub mod artifact; pub mod artifact_snapshot; pub mod artifact_upload; pub(crate) mod condition; +pub mod config; pub mod context; pub mod devcontainer_bridge; pub mod error; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index fe22570be..00fc3a266 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -3,7 +3,9 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::{RunId, RunProvenance, Settings}; +use fabro_types::settings::run::{RunGoalLayer, RunLayer, RunModelLayer}; +use fabro_types::settings::{InterpString, SettingsFile}; +use fabro_types::{RunId, RunProvenance}; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -26,7 +28,7 @@ use crate::event::{Event, append_event, to_run_event_at}; #[derive(Clone, Debug)] pub struct CreateRunInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, pub workflow_slug: Option, pub workflow_path: Option, @@ -48,7 +50,7 @@ pub struct CreatedRun { } struct PersistCreateOptions { - settings: Settings, + settings: SettingsFile, run_id: Option, run_dir: Option, workflow_slug: Option, @@ -125,7 +127,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result FabroError { FabroError::engine(err.to_string()) } -fn validate_sandbox_provider(settings: &Settings) -> Result<(), FabroError> { +fn validate_sandbox_provider(settings: &SettingsFile) -> Result<(), FabroError> { if let Some(provider) = settings - .sandbox_settings() + .run_sandbox() .and_then(|sandbox| sandbox.provider.as_deref()) { provider @@ -290,12 +292,11 @@ pub(super) fn preprocess_and_validate( current_dir: Option, file_resolver: Option>, custom_transforms: Vec>, - settings: Option<&Settings>, + settings: Option<&SettingsFile>, goal_override: Option<&str>, ) -> Result { - let source = match settings.and_then(|resolved| resolved.vars.as_ref()) { - Some(vars) => { - let mut vars = vars.clone(); + let source = match settings.and_then(SettingsFile::run_inputs_as_strings) { + Some(mut vars) => { vars.insert("goal".to_string(), "$goal".to_string()); expand_vars(dot_source, &vars) .map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))? @@ -372,28 +373,32 @@ fn persist_validated( ) } -pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Settings { - let llm_settings = settings.llm.as_ref(); - let configured_model = llm_settings.and_then(|l| l.model.as_deref()); - let configured_provider = llm_settings.and_then(|l| l.provider.as_deref()); - let graph_provider = graph.attrs.get("default_provider").and_then(|v| v.as_str()); - let graph_model = graph.attrs.get("default_model").and_then(|v| v.as_str()); +pub(crate) fn resolve_run_settings(mut settings: SettingsFile, graph: &Graph) -> SettingsFile { + let configured_model = settings.run_model_name_str(); + let configured_provider = settings.run_model_provider_str(); + let graph_provider = graph + .attrs + .get("default_provider") + .and_then(|v| v.as_str()) + .map(str::to_string); + let graph_model = graph + .attrs + .get("default_model") + .and_then(|v| v.as_str()) + .map(str::to_string); - let provider = configured_provider.or(graph_provider).map(str::to_string); + let provider = configured_provider.or(graph_provider); - let model = configured_model.or(graph_model).map_or_else( - || { - let catalog = Catalog::builtin(); - provider - .as_deref() - .and_then(|value| value.parse::().ok()) - .and_then(|provider| catalog.default_for_provider(provider)) - .unwrap_or_else(|| catalog.default_from_env()) - .id - .clone() - }, - str::to_string, - ); + let model = configured_model.or(graph_model).unwrap_or_else(|| { + let catalog = Catalog::builtin(); + provider + .as_deref() + .and_then(|value| value.parse::().ok()) + .and_then(|provider| catalog.default_for_provider(provider)) + .unwrap_or_else(|| catalog.default_from_env()) + .id + .clone() + }); let (resolved_model, resolved_provider) = match Catalog::builtin().get(&model) { Some(info) => ( @@ -403,16 +408,26 @@ pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Set None => (model, provider), }; - let llm = settings.llm.get_or_insert_default(); - llm.model = Some(resolved_model); - llm.provider = resolved_provider; + 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(&resolved_model)); + model_layer.provider = resolved_provider.as_deref().map(InterpString::parse); let goal = graph.goal().to_string(); - settings.goal = if goal.is_empty() { None } else { Some(goal) }; - settings.pull_request = settings + run.goal = if goal.is_empty() { + None + } else { + Some(RunGoalLayer::Inline(InterpString::parse(&goal))) + }; + // Strip disabled pull_request entries so downstream consumers can + // treat `Some(_)` as "PR creation is on". + if run .pull_request - .take() - .filter(|pull_request| pull_request.enabled); + .as_ref() + .is_some_and(|pr| !pr.enabled.unwrap_or(false)) + { + run.pull_request = None; + } settings } @@ -449,7 +464,7 @@ mod tests { )) } - fn validate_dot(dot_source: &str, settings: Settings) -> Validated { + fn validate_dot(dot_source: &str, settings: SettingsFile) -> Validated { validate(ValidateInput { workflow: WorkflowInput::DotSource { source: dot_source.to_string(), @@ -471,7 +486,7 @@ mod tests { #[test] fn validate_minimal() { - let validated = validate_dot(MINIMAL_DOT, Settings::default()); + let validated = validate_dot(MINIMAL_DOT, SettingsFile::default()); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().name, "Test"); @@ -488,7 +503,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); validated.raise_on_errors().unwrap(); let prompt = validated.graph().nodes["work"] @@ -526,7 +541,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); validated.raise_on_errors().unwrap(); assert_eq!( @@ -544,14 +559,19 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot( - dot, - Settings { - vars: Some(HashMap::from([("who".to_string(), "agent".to_string())])), - goal: Some("override".to_string()), - ..Default::default() - }, - ); + let validated = validate_dot(dot, { + use fabro_types::settings::run::{RunGoalLayer, RunLayer}; + let mut inputs = std::collections::HashMap::new(); + inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); + SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), + inputs: Some(inputs), + ..RunLayer::default() + }), + ..SettingsFile::default() + } + }); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().goal(), "override"); @@ -570,7 +590,7 @@ mod tests { source: "not a graph".to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }); @@ -583,7 +603,7 @@ mod tests { graph [goal="Test"] work [label="Work"] }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); assert!(validated.has_errors()); assert!(validated.raise_on_errors().is_err()); @@ -610,7 +630,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: vec![Box::new(TagTransform)], }) @@ -643,7 +663,7 @@ mod tests { let validated = validate(ValidateInput { workflow: WorkflowInput::Path(dot_path), - settings: Settings::default(), + settings: SettingsFile::default(), cwd: dir.path().to_path_buf(), custom_transforms: Vec::new(), }) @@ -679,7 +699,7 @@ mod tests { (PathBuf::from("prompts/lint.md"), "Lint $goal".to_string()), ]), }), - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }) @@ -710,7 +730,7 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -745,20 +765,33 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - llm: Some(fabro_config::run::LlmSettings { - model: Some("sonnet".to_string()), - provider: None, - fallbacks: None, - }), - pull_request: Some(fabro_config::run::PullRequestSettings { - enabled: false, - ..Default::default() - }), - goal: Some("override goal".to_string()), - dry_run: Some(true), - labels: HashMap::from([("env".to_string(), "test".to_string())]), - ..Default::default() + settings: { + use fabro_types::settings::run::{ + RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, + RunPullRequestLayer, + }; + let mut metadata = HashMap::new(); + metadata.insert("env".to_string(), "test".to_string()); + SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), + metadata, + model: Some(RunModelLayer { + name: Some(InterpString::parse("sonnet")), + ..RunModelLayer::default() + }), + pull_request: Some(RunPullRequestLayer { + enabled: Some(false), + ..RunPullRequestLayer::default() + }), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + } }, cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), @@ -782,9 +815,8 @@ mod tests { .persisted .run_record() .settings - .llm - .as_ref() - .and_then(|llm| llm.model.as_deref()), + .run_model_name_str() + .as_deref(), Some("claude-sonnet-4-6") ); assert_eq!( @@ -792,13 +824,17 @@ mod tests { .persisted .run_record() .settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()), + .run_model_provider_str() + .as_deref(), Some("anthropic") ); assert_eq!( - created.persisted.run_record().settings.goal.as_deref(), + created + .persisted + .run_record() + .settings + .run_goal_inline_str() + .as_deref(), Some("override goal") ); assert!( @@ -806,7 +842,7 @@ mod tests { .persisted .run_record() .settings - .pull_request + .run_pull_request() .is_none() ); assert_eq!( @@ -836,10 +872,19 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - work_dir: Some("workspace".to_string()), - dry_run: Some(true), - ..Default::default() + settings: { + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + SettingsFile { + run: Some(RunLayer { + working_dir: Some(InterpString::parse("workspace")), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + } }, cwd: dir.path().to_path_buf(), workflow_slug: None, @@ -881,10 +926,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_only_settings(), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -906,6 +948,41 @@ mod tests { ); } + fn dry_run_only_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() + } + } + + fn dry_run_with_storage(storage_dir: &Path) -> SettingsFile { + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; + SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + server: Some(ServerLayer { + storage: Some(ServerStorageLayer { + root: Some(InterpString::parse(&storage_dir.to_string_lossy())), + }), + ..ServerLayer::default() + }), + ..SettingsFile::default() + } + } + #[tokio::test] async fn create_hydrates_run_created_event_into_store() { let dir = tempfile::tempdir().unwrap(); @@ -921,11 +998,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - storage_dir: Some(storage_dir.clone()), - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_with_storage(&storage_dir), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, @@ -964,11 +1037,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - storage_dir: Some(storage_dir.clone()), - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_with_storage(&storage_dir), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 1f8e7a1be..93f920310 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -335,7 +335,8 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::Graph; use fabro_store::{Database, StageId}; - use fabro_types::{RunId, RunRecord, SandboxRecord, Settings, StartRecord, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{RunId, RunRecord, SandboxRecord, StartRecord, fixtures}; use object_store::memory::InMemory; use std::collections::HashMap; use std::path::PathBuf; @@ -370,7 +371,7 @@ mod tests { fn sample_run_record(run_id: RunId, host_repo_path: Option<&str>) -> RunRecord { RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index dcf9c06ae..93c1e5321 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use anyhow::Context; use fabro_config::project as project_config; -use fabro_types::Settings; -use fabro_util::path::expand_tilde; +use fabro_types::settings::SettingsFile; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -22,14 +21,14 @@ pub enum WorkflowInput { #[derive(Clone, Debug)] pub(crate) struct ResolveWorkflowInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, } #[derive(Clone)] pub(crate) struct ResolvedWorkflow { pub raw_source: String, - pub settings: Settings, + pub settings: SettingsFile, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, @@ -39,25 +38,6 @@ pub(crate) struct ResolvedWorkflow { pub working_directory: PathBuf, } -fn resolve_goal_file( - goal_file: Option<&Path>, - working_directory: &Path, -) -> anyhow::Result> { - let Some(goal_file) = goal_file else { - return Ok(None); - }; - let expanded = expand_tilde(goal_file); - let goal_path = if expanded.is_absolute() { - expanded - } else { - working_directory.join(expanded) - }; - let content = std::fs::read_to_string(&goal_path) - .with_context(|| format!("failed to read goal file: {}", goal_path.display()))?; - tracing::debug!(path = %goal_path.display(), "Goal loaded from file"); - Ok(Some(content)) -} - fn workflow_slug_from_path(workflow_path: &Path) -> Option { let file_name = workflow_path.file_name()?.to_string_lossy(); if workflow_path.extension().is_none() { @@ -85,10 +65,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< project_config::resolve_working_directory(&settings, &request.cwd); let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; - let goal_override = settings.goal.clone().or(resolve_goal_file( - settings.goal_file.as_deref(), - &working_directory, - )?); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let current_dir = resolution .dot_path .parent() @@ -113,10 +90,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); - let goal_override = settings.goal.clone().or(resolve_goal_file( - settings.goal_file.as_deref(), - &working_directory, - )?); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { raw_source: source, @@ -138,37 +112,58 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); + let goal_override = resolve_goal_override(&settings, &working_directory)?; Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), - settings: settings.clone(), + settings, workflow_slug: workflow_slug_from_path(&workflow.logical_path), workflow_toml_path: None, dot_path: Some(workflow.logical_path.clone()), current_dir: Some(workflow.current_dir()), file_resolver: Some(workflow.file_resolver()), - goal_override: settings.goal.clone(), + goal_override, working_directory, }) } } } +/// Resolve the `run.goal` override for a direct (non-manifest) workflow +/// run. Reads the file from disk if the goal layer is the `file` variant. +/// Relative paths that survived config load (e.g. env-interpolated ones) +/// are anchored at `working_directory`. +fn resolve_goal_override( + settings: &SettingsFile, + working_directory: &Path, +) -> anyhow::Result> { + settings + .resolve_run_goal(working_directory) + .map(|opt| opt.map(|resolved| resolved.text)) + .map_err(anyhow::Error::from) +} + #[cfg(test)] mod tests { use super::*; #[test] fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() { + use fabro_types::settings::InterpString; + use fabro_types::settings::run::RunLayer; + let dir = tempfile::tempdir().unwrap(); let resolved = resolve_workflow(ResolveWorkflowInput { workflow: WorkflowInput::DotSource { source: "digraph Test { start -> exit }".to_string(), base_dir: None, }, - settings: Settings { - work_dir: Some("workspace".to_string()), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + working_dir: Some(InterpString::parse("workspace")), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: dir.path().to_path_buf(), }) diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 40bef59d0..9fefa5ff0 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -4,12 +4,20 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use fabro_config::sandbox::WorktreeMode; -use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; +use fabro_config::project as project_config; +use fabro_hooks::config::bridge_hook; use fabro_interview::{AutoApproveInterviewer, Interviewer}; +use fabro_mcp::config::bridge_mcp_entry; use fabro_model::{Catalog, FallbackTarget, Provider}; +use fabro_sandbox::config::{ + self as sandbox_config, WorktreeMode, bridge_sandbox, bridge_worktree_mode, +}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::run::ModelRefOrSplice; +use fabro_types::settings::{InterpString, SettingsFile}; + +use crate::config::{PullRequestSettings, bridge_pull_request}; use crate::artifact_upload::ArtifactSink; use crate::context::Context; @@ -31,7 +39,6 @@ use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::run_status::{RunStatus, StatusReason}; use crate::runtime_store::RunStoreHandle; use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; -use fabro_config::run::PullRequestSettings; use fabro_retro::retro::Retro; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::daytona::detect_repo_info; @@ -260,7 +267,7 @@ async fn persist_terminal_engine_failure( impl RunSession { async fn new(persisted: &Persisted, services: StartServices) -> Result { let record = persisted.run_record(); - let mut settings = record.settings.clone(); + let settings = &record.settings; let working_directory = record.working_directory.clone(); let state = services .run_store @@ -287,34 +294,21 @@ impl RunSession { let workflow_bundle = accepted_definition.map(|definition| Arc::new(definition.workflow_bundle())); - if let Some(env) = settings - .sandbox - .as_mut() - .and_then(|sandbox| sandbox.env.as_mut()) - { - run_config::resolve_env_refs(env) - .map_err(|err| FabroError::Precondition(err.to_string()))?; - } - let (origin_url, detected_base_branch) = detect_repo_info(&working_directory) .map(|(url, branch)| (Some(url), branch)) .unwrap_or((None, None)); - let sandbox_provider = resolve_sandbox_provider(&settings)?; + let sandbox_provider = resolve_sandbox_provider(settings)?; let sandbox_provider = if settings.dry_run_enabled() && !sandbox_provider.is_local() { SandboxProvider::Local } else { sandbox_provider }; let model = settings - .llm - .as_ref() - .and_then(|llm| llm.model.clone()) + .run_model_name_str() .unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone()); let provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.clone()) + .run_model_provider_str() .filter(|value| !value.is_empty()); let provider_enum: Provider = provider @@ -324,13 +318,15 @@ impl RunSession { .map_err(|err| FabroError::Precondition(err.clone()))? .unwrap_or_else(Provider::default_from_env); - let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings); + let fallback_chain = resolve_fallback_chain(provider_enum, &model, settings); let mcp_servers = settings - .mcp_server_entries() - .clone() - .into_iter() - .map(|(name, entry)| entry.into_config(name)) - .collect(); + .run_agent_mcps() + .map(|mcps| { + mcps.iter() + .map(|(name, entry)| bridge_mcp_entry(entry).into_config(name.clone())) + .collect() + }) + .unwrap_or_default(); let sandbox = match sandbox_provider { SandboxProvider::Local => SandboxSpec::Local { @@ -343,26 +339,39 @@ impl RunSession { }, }, SandboxProvider::Daytona => SandboxSpec::Daytona { - config: resolve_daytona_config(&settings).unwrap_or_default(), + config: resolve_daytona_config(settings).unwrap_or_default(), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()), }, }; + let toml_env: HashMap = settings + .run_sandbox() + .map(|sb| { + sb.env + .iter() + .map(|(k, v)| (k.clone(), resolve_interp(v))) + .collect() + }) + .unwrap_or_default(); + let github_permissions: Option> = + settings.github_permissions().map(|perms| { + perms + .iter() + .map(|(k, v)| (k.clone(), resolve_interp(v))) + .collect() + }); let sandbox_env = SandboxEnvSpec { devcontainer_env: HashMap::new(), - toml_env: settings - .sandbox_settings() - .and_then(|sandbox| sandbox.env.clone()) - .unwrap_or_default(), - github_permissions: settings.github_permissions().cloned(), + toml_env, + github_permissions, origin_url: origin_url.clone(), }; let devcontainer = settings - .sandbox_settings() - .and_then(|sandbox| sandbox.devcontainer) + .run_sandbox() + .and_then(|sb| sb.devcontainer) .unwrap_or(false) .then(|| DevcontainerSpec { enabled: true, @@ -375,6 +384,8 @@ impl RunSession { services.interviewer }; + let pr_config = settings.run_pull_request().map(bridge_pull_request); + Ok(Self { cancel_token: services.cancel_token, emitter: services.emitter, @@ -391,12 +402,12 @@ impl RunSession { interviewer, on_node: services.on_node, lifecycle: LifecycleOptions { - setup_commands: settings.setup_commands().to_vec(), - setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000), + setup_commands: settings.run_prepare_commands(), + setup_command_timeout_ms: settings.run_prepare_timeout_ms().unwrap_or(300_000), devcontainer_phases: Vec::new(), }, hooks: fabro_hooks::HookSettings { - hooks: settings.hooks.clone(), + hooks: settings.run_hooks().iter().map(bridge_hook).collect(), }, sandbox_env, devcontainer, @@ -405,11 +416,11 @@ impl RunSession { artifact_sink: services.artifact_sink, git, github_app: services.github_app.clone(), - worktree_mode: Some(resolve_worktree_mode(&settings)), + worktree_mode: Some(resolve_worktree_mode(settings)), registry_override: services.registry_override, retro_enabled: !settings.no_retro_enabled() && project_config::is_retro_enabled(), - preserve_sandbox: resolve_preserve_sandbox(&settings), - pr_config: settings.pull_request.clone(), + preserve_sandbox: resolve_preserve_sandbox(settings), + pr_config, pr_github_app: services.github_app, pr_origin_url: origin_url, pr_model: model, @@ -419,6 +430,12 @@ impl RunSession { } } +fn resolve_interp(value: &InterpString) -> String { + value + .resolve(|name| std::env::var(name).ok()) + .map_or_else(|_| value.as_source(), |resolved| resolved.value) +} + async fn load_accepted_run_definition( run_store: &RunStoreHandle, blob_id: fabro_types::RunBlobId, @@ -435,45 +452,62 @@ async fn load_accepted_run_definition( serde_json::from_slice(&bytes).map_err(|err| FabroError::Parse(err.to_string())) } -fn resolve_sandbox_provider(settings: &Settings) -> Result { +fn resolve_sandbox_provider(settings: &SettingsFile) -> Result { settings - .sandbox_settings() - .and_then(|sandbox| sandbox.provider.as_deref()) + .run_sandbox() + .and_then(|sb| sb.provider.as_deref()) .map(str::parse::) .transpose() .map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))? .map_or_else(|| Ok(SandboxProvider::default()), Ok) } -fn resolve_preserve_sandbox(settings: &Settings) -> bool { +fn resolve_preserve_sandbox(settings: &SettingsFile) -> bool { settings.preserve_sandbox_enabled() } -fn resolve_worktree_mode(settings: &Settings) -> sandbox_config::WorktreeMode { +fn resolve_worktree_mode(settings: &SettingsFile) -> sandbox_config::WorktreeMode { settings - .sandbox_settings() - .and_then(|sandbox| sandbox.local.as_ref()) - .map(|local| local.worktree_mode) + .run_sandbox() + .and_then(|sb| sb.local.as_ref()) + .and_then(|local| local.worktree_mode) + .map(bridge_worktree_mode) .unwrap_or_default() } -fn resolve_daytona_config(settings: &Settings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) +fn resolve_daytona_config(settings: &SettingsFile) -> Option { + let sandbox = settings.run_sandbox()?; + bridge_sandbox(sandbox).daytona } fn resolve_fallback_chain( provider: Provider, model: &str, - settings: &Settings, + settings: &SettingsFile, ) -> Vec { - let fallbacks = settings.llm.as_ref().and_then(|llm| llm.fallbacks.as_ref()); - - match fallbacks { - Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map), - None => Vec::new(), + let Some(model_layer) = settings.run_model() else { + return Vec::new(); + }; + if model_layer.fallbacks.is_empty() { + return Vec::new(); } + // Group v2 ModelRef entries by provider name, preserving the legacy + // shape expected by `Catalog::build_fallback_chain`. The historical + // bridge grouped all fallback tokens under the empty-string key; we + // preserve that behavior here so `Catalog::build_fallback_chain` + // returns an empty chain unless a consumer has explicitly wired + // provider-keyed fallbacks. A proper provider-aware fallback chain + // is a follow-up along with the model registry work. + let mut by_provider: HashMap> = HashMap::new(); + for entry in &model_layer.fallbacks { + if let ModelRefOrSplice::ModelRef(model_ref) = entry { + by_provider + .entry(String::new()) + .or_default() + .push(model_ref.to_string()); + } + } + Catalog::builtin().build_fallback_chain(provider, model, &by_provider) } impl RunSession { @@ -554,7 +588,7 @@ impl RunSession { checkpoint, seed_context: self.seed_context, }; - let mut initialized = pipeline::initialize(persisted, init_options).await?; + let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?; initialized.on_node = on_node; let sandbox_for_cleanup = Arc::clone(&initialized.sandbox); @@ -616,8 +650,8 @@ impl RunSession { }; let retro = retroed.retro.clone(); - let concluded = pipeline::finalize(retroed, &finalize_opts).await?; - let finalized = pipeline::pull_request(concluded, &pr_opts).await; + let concluded = Box::pin(pipeline::finalize(retroed, &finalize_opts)).await?; + let finalized = Box::pin(pipeline::pull_request(concluded, &pr_opts)).await; store_progress_logger.flush().await; scopeguard::ScopeGuard::into_inner(cleanup_guard); @@ -817,7 +851,8 @@ mod tests { use chrono::Utc; use fabro_store::Database; - use fabro_types::{Settings, fixtures}; + use fabro_types::fixtures; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use super::*; @@ -855,9 +890,15 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: Settings { - dry_run: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: run_dir .parent() @@ -1035,9 +1076,15 @@ mod tests { .unwrap() .clone(), ), - settings: Settings { - dry_run: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: temp.path().to_path_buf(), workflow_slug: Some("bundle-child".to_string()), diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index 46c9f9e82..24feb395f 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/crates/fabro-workflow/src/operations/validate.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use fabro_types::Settings; +use fabro_types::settings::SettingsFile; use crate::error::FabroError; use crate::pipeline::Validated; @@ -11,7 +11,7 @@ use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; pub struct ValidateInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, pub custom_transforms: Vec>, } diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index b88ef269a..c03d18480 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -13,7 +13,8 @@ use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; -use fabro_types::{RunId, Settings, fixtures}; +use fabro_types::settings::SettingsFile; +use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -90,7 +91,7 @@ fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions { run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(run_id), - settings: Settings::default(), + settings: SettingsFile::default(), git: None, host_repo_path: None, labels: HashMap::new(), @@ -132,7 +133,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI run_dir.to_path_buf(), RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index daa499344..33425e5c6 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -306,7 +306,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -320,7 +321,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 2b8517de3..7eba23f4a 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -534,8 +534,16 @@ pub async fn initialize( build_registry(&options.llm, Arc::clone(&options.interviewer), &env, &graph).await? }; if effective_dry_run { + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + options.dry_run = true; - options.run_options.settings.dry_run = Some(true); + let run = options + .run_options + .settings + .run + .get_or_insert_with(RunLayer::default); + let execution = run.execution.get_or_insert_with(RunExecutionLayer::default); + execution.mode = Some(RunMode::DryRun); } let has_run_branch = options @@ -680,7 +688,8 @@ mod tests { use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -727,7 +736,7 @@ mod tests { fn test_settings(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), @@ -749,7 +758,7 @@ mod tests { run_dir.to_path_buf(), RunRecord { run_id: test_run_id(), - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap(), diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 2b1cb5fc2..68537edb3 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -54,7 +54,10 @@ mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_store::{Database, RunDatabase}; - use fabro_types::{Settings, fixtures}; + use fabro_types::fixtures; + use fabro_types::settings::SettingsFile; + use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use std::sync::Arc; use std::time::Duration; @@ -118,10 +121,22 @@ mod tests { fn sample_record(graph: Graph) -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings { - dry_run: Some(true), - verbose: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + cli: Some(CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }), + ..SettingsFile::default() }, graph, workflow_slug: Some("ship".to_string()), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 914a9daf5..d00093cb3 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1,4 +1,4 @@ -use fabro_config::run::MergeStrategy; +use crate::config::MergeStrategy; use fabro_store::RunProjection; use fabro_types::PullRequestRecord; use tracing::{debug, info}; @@ -595,7 +595,8 @@ mod tests { AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro, }; use fabro_store::Database; - use fabro_types::{BilledTokenCounts, RunRecord, Settings, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{BilledTokenCounts, RunRecord, fixtures}; use futures::stream; use object_store::memory::InMemory; use std::time::Duration; @@ -1082,7 +1083,7 @@ mod tests { let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1155,7 +1156,7 @@ mod tests { let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1381,7 +1382,7 @@ mod tests { let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: tmp.path().to_path_buf(), diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index 1511c0ced..82db57ebd 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -184,7 +184,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -233,7 +234,7 @@ mod tests { let run_store = inner; let run_record = RunRecord { run_id: test_run_id(), - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: run_dir.to_path_buf(), @@ -304,7 +305,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index c2ae831aa..653a9fc12 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use fabro_agent::Sandbox; -use fabro_config::sandbox::WorktreeMode; use fabro_graphviz::graph::Graph; use fabro_hooks::HookRunner; use fabro_interview::Interviewer; @@ -11,10 +10,12 @@ use fabro_llm::Provider; use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_sandbox::SandboxSpec; +use fabro_sandbox::config::WorktreeMode; use fabro_types::RunId; use fabro_validate::Diagnostic; use crate::artifact_upload::ArtifactSink; +use crate::config::PullRequestSettings; use crate::context::Context; use crate::error::FabroError; use crate::event::Emitter; @@ -27,7 +28,6 @@ use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::runtime_store::RunStoreHandle; use crate::transforms::Transform; use crate::workflow_bundle::WorkflowBundle; -use fabro_config::run::PullRequestSettings; use fabro_llm::client::Client; use fabro_retro::retro::Retro; use fabro_validate::Severity; diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index d0277a728..ed0546c2c 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -5,7 +5,8 @@ use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use fabro_config::Storage; use fabro_store::{Database, RunSummary}; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::SettingsFile; use serde::Serialize; use crate::operations::make_run_dir; @@ -141,7 +142,7 @@ pub fn scratch_base(storage_dir: &Path) -> PathBuf { } pub fn default_scratch_base() -> PathBuf { - scratch_base(&Settings::default().storage_dir()) + scratch_base(&SettingsFile::default().storage_dir()) } fn scan_orphan_runs(base: &Path) -> Result> { @@ -396,7 +397,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunStatus, Settings, fixtures}; + use fabro_types::settings::SettingsFile; + use fabro_types::{RunStatus, fixtures}; use object_store::memory::InMemory; use super::scan_runs_combined; @@ -415,7 +417,7 @@ mod tests { fn sample_run_record() -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index a01da6471..ddcf93cb2 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -3,8 +3,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use fabro_config::run::PullRequestSettings; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::RunPullRequestLayer; use crate::git::{GitAuthor, git_author_from_settings}; @@ -19,7 +20,7 @@ pub struct GitCheckpointOptions { /// Options for a workflow run. #[derive(Clone)] pub struct RunOptions { - pub settings: Settings, + pub settings: SettingsFile, pub run_dir: PathBuf, pub cancel_token: Option>, /// Unique identifier for this workflow run. @@ -46,22 +47,23 @@ impl RunOptions { } pub fn checkpoint_exclude_globs(&self) -> &[String] { - &self.settings.checkpoint.exclude_globs + self.settings + .run_checkpoint() + .map_or(&[], |cp| cp.exclude_globs.as_slice()) } pub fn git_author(&self) -> GitAuthor { git_author_from_settings(&self.settings) } - /// PR config (already normalized — disabled entries stripped at construction). - pub fn pull_request(&self) -> Option<&PullRequestSettings> { - self.settings.pull_request.as_ref() + /// PR config, if present in the v2 run layer. + pub fn pull_request(&self) -> Option<&RunPullRequestLayer> { + self.settings.run_pull_request() } pub fn artifact_globs(&self) -> &[String] { self.settings - .artifacts - .as_ref() + .run_artifacts() .map_or(&[], |a| a.include.as_slice()) } } diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index 6eb093010..c83dbdb6f 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -113,7 +113,8 @@ mod tests { use fabro_store::Database; use fabro_types::fixtures; use fabro_types::run_event::RunSubmittedProps; - use fabro_types::{EventBody, RunEvent, Settings}; + use fabro_types::settings::SettingsFile; + use fabro_types::{EventBody, RunEvent}; use object_store::memory::InMemory; use super::RunStoreHandle; @@ -132,7 +133,7 @@ mod tests { fn test_run_record() -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/test"), diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index ebe61f89d..b8763c29a 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -339,13 +339,13 @@ impl WorkflowRunner { .unwrap() .take() .expect("WorkflowRunner may only be used once"); - run_graph( + Box::pin(run_graph( registry, Arc::clone(&self.emitter), Arc::clone(&self.sandbox), graph, run_options, - ) + )) .await } @@ -382,14 +382,14 @@ impl WorkflowRunner { .unwrap() .take() .expect("WorkflowRunner may only be used once"); - run_graph_from_checkpoint( + Box::pin(run_graph_from_checkpoint( registry, Arc::clone(&self.emitter), Arc::clone(&self.sandbox), graph, run_options, checkpoint, - ) + )) .await } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 514a38eca..b206f23cb 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -22,7 +22,9 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::{RunId, Settings, StageId}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; +use fabro_types::{RunId, StageId}; use fabro_workflow::artifact::sync_artifacts_to_env; use fabro_workflow::context::Context; use fabro_workflow::error::FabroError; @@ -496,7 +498,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -674,7 +676,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("git-cp-test"), @@ -845,7 +847,7 @@ async fn daytona_parallel_git_branching_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env)); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_tmp.path().to_path_buf(), cancel_token: None, run_id, @@ -1192,7 +1194,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id.to_string()); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, @@ -1326,11 +1328,14 @@ async fn daytona_asset_collection() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: dir.path().to_path_buf(), cancel_token: None, @@ -1586,7 +1591,7 @@ async fn daytona_git_push_run_branch_to_origin() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 6fd54edf6..b3c440fc4 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -26,7 +26,9 @@ use fabro_interview::{ }; use fabro_llm::provider::Provider; use fabro_store::{ArtifactStore, Database}; -use fabro_types::{RunEvent, RunId, Settings, StageId}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; +use fabro_types::{RunEvent, RunId, StageId}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{FabroError, FailureSignatureExt}; @@ -336,7 +338,7 @@ async fn end_to_end_linear_pipeline() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -465,7 +467,7 @@ async fn end_to_end_branching_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -584,7 +586,7 @@ async fn end_to_end_human_gate_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -679,7 +681,7 @@ async fn human_gate_interrupted_input_fails_closed_without_fail_route() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -789,7 +791,7 @@ async fn human_gate_interrupted_input_routes_via_outcome_fail_condition() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -901,7 +903,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1021,7 +1023,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1332,7 +1334,7 @@ async fn retry_on_failure_then_succeed() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1406,7 +1408,7 @@ async fn pipeline_with_many_nodes() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1751,7 +1753,7 @@ async fn smoke_test_with_mock_codergen_backend() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1852,7 +1854,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1964,7 +1966,7 @@ async fn resume_from_checkpoint_completes_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2062,7 +2064,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2104,7 +2106,7 @@ async fn graph_goal_in_context() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2142,7 +2144,7 @@ async fn event_streaming_lifecycle() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2221,7 +2223,7 @@ async fn context_flow_between_stages() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2276,7 +2278,7 @@ async fn tool_handler_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2350,7 +2352,7 @@ async fn auto_approve_interviewer_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2389,7 +2391,7 @@ async fn codergen_without_backend_simulated() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2493,7 +2495,7 @@ async fn branching_loop_back_on_failure() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2578,7 +2580,7 @@ async fn human_gate_loops_back() { registry.register("human", Box::new(HumanHandler::new(interviewer))); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2638,7 +2640,7 @@ async fn scenario_ship_a_feature() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2722,7 +2724,7 @@ async fn scenario_parallel_expert_review() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2808,7 +2810,7 @@ async fn scenario_node_retries_on_retry_status() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2872,7 +2874,7 @@ async fn scenario_loop_restart_resets_context() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2939,7 +2941,7 @@ async fn scenario_bug_triage_router() { registry.register("conditional", Box::new(ConditionalHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3000,7 +3002,7 @@ async fn scenario_crash_recovery() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3108,7 +3110,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3189,7 +3191,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3329,7 +3331,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3384,7 +3386,7 @@ async fn edge_selection_condition_match_wins_over_weight() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3433,7 +3435,7 @@ async fn edge_selection_weight_breaks_ties() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3474,7 +3476,7 @@ async fn edge_selection_lexical_tiebreak() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3534,7 +3536,7 @@ async fn context_updates_visible_across_nodes() { registry.register("context_setter", Box::new(ContextSetterHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3580,7 +3582,7 @@ async fn stylesheet_applies_model_override() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3635,7 +3637,7 @@ async fn custom_handler_registration_and_execution() { registry.register("my_custom", Box::new(CustomHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3708,7 +3710,7 @@ async fn integration_smoke_plan_implement_review_done() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3799,7 +3801,7 @@ async fn manager_loop_runs_child_engine_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3932,7 +3934,7 @@ async fn manager_loop_context_flows_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4007,7 +4009,7 @@ async fn manager_loop_child_dotfile_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4111,7 +4113,7 @@ async fn import_e2e_through_engine() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4264,7 +4266,7 @@ async fn fidelity_default_is_compact() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4320,7 +4322,7 @@ async fn fidelity_graph_default_applied() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4372,7 +4374,7 @@ async fn fidelity_node_overrides_graph_default() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4430,7 +4432,7 @@ async fn fidelity_edge_overrides_node_and_graph() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4478,7 +4480,7 @@ async fn fidelity_full_produces_empty_preamble() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4536,7 +4538,7 @@ async fn fidelity_truncate_preamble_minimal() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4607,7 +4609,7 @@ async fn fidelity_summary_low_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4673,7 +4675,7 @@ async fn fidelity_summary_medium_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4739,7 +4741,7 @@ async fn fidelity_summary_high_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4798,7 +4800,7 @@ async fn fidelity_full_sets_thread_id_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4868,7 +4870,7 @@ async fn fidelity_full_nodes_share_thread_id() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4948,7 +4950,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5044,7 +5046,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5127,7 +5129,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5168,7 +5170,7 @@ async fn fidelity_stored_in_checkpoint_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5260,7 +5262,7 @@ async fn fidelity_precedence_multi_node_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5327,7 +5329,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5401,7 +5403,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_low = WorkflowRunner::new(registry_low, Arc::new(Emitter::default()), local_env()); let run_options_low = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir_low.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5467,7 +5469,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_med = WorkflowRunner::new(registry_med, Arc::new(Emitter::default()), local_env()); let run_options_med = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir_med.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5537,7 +5539,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5590,7 +5592,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5646,7 +5648,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5703,7 +5705,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5770,7 +5772,7 @@ async fn fidelity_from_parsed_dot_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5817,7 +5819,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5888,7 +5890,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5974,7 +5976,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6017,7 +6019,7 @@ mod real_llm { use async_trait::async_trait; use fabro_graphviz::graph::Node; - use fabro_types::Settings; + use fabro_types::settings::SettingsFile; use fabro_workflow::context::Context; use fabro_workflow::error::FabroError; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; @@ -6211,7 +6213,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6319,7 +6321,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6451,7 +6453,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6551,7 +6553,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6644,7 +6646,7 @@ async fn human_gate_freeform_only_routes_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6773,7 +6775,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6887,7 +6889,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7014,7 +7016,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7121,7 +7123,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7421,7 +7423,7 @@ fn engine_with_hooks_and_events( fn make_run_options(dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.to_path_buf(), cancel_token: None, run_id: test_run_id("hook-test-run"), @@ -8087,41 +8089,10 @@ async fn hook_config_merge_run_overrides_by_name() { assert_eq!(outcome.status, StageStatus::Success); } -// --- TOML config parsing integration --- - -#[test] -fn hook_toml_run_config_parsing() { - let toml = r#" -version = 1 -goal = "Test hooks in run config" -graph = "test.fabro" - -[[hooks]] -event = "stage_start" -command = "./scripts/pre-check.sh" -matcher = "agent_loop" -blocking = true -timeout_ms = 30000 -sandbox = false - -[[hooks]] -event = "run_complete" -command = "echo done" -"#; - - let cfg: Settings = toml::from_str(toml).unwrap(); - assert_eq!(cfg.hooks.len(), 2); - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart); - assert_eq!(cfg.hooks[0].matcher.as_deref(), Some("agent_loop")); - assert!(cfg.hooks[0].is_blocking()); - assert!(!cfg.hooks[0].runs_in_sandbox()); - assert_eq!( - cfg.hooks[0].timeout(), - std::time::Duration::from_millis(30000) - ); - assert_eq!(cfg.hooks[1].event, fabro_hooks::HookEvent::RunComplete); - assert!(!cfg.hooks[1].is_blocking()); // RunComplete non-blocking by default -} +// The legacy `Settings`-based TOML parsing tests were deleted in Stage +// 6.3b. Hook TOML parsing now flows through the v2 `SettingsFile` path, +// with coverage in `fabro-types::settings::tree::tests` and the +// fabro-cli integration tests under `cmd::config`. // --- Blocking vs non-blocking behavior --- @@ -8268,59 +8239,9 @@ async fn hook_sandbox_false_runs_on_host() { assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "host"); } -// --- Prompt and Agent hook TOML parsing --- - -#[test] -fn hook_toml_prompt_and_agent_parsing() { - let toml = r#" -version = 1 -goal = "Test prompt/agent hooks" -graph = "test.fabro" - -[[hooks]] -event = "stage_start" -type = "prompt" -prompt = "Should this stage proceed?" -model = "haiku" - -[[hooks]] -event = "run_complete" -type = "agent" -prompt = "Verify all tests pass." -model = "sonnet" -max_tool_rounds = 10 -timeout_ms = 120000 -"#; - - let cfg: Settings = toml::from_str(toml).unwrap(); - assert_eq!(cfg.hooks.len(), 2); - - // Prompt hook - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart); - assert!(matches!( - cfg.hooks[0].resolved_hook_type().as_deref(), - Some(fabro_hooks::HookType::Prompt { prompt, model }) - if prompt == "Should this stage proceed?" && *model == Some("haiku".into()) - )); - assert_eq!( - cfg.hooks[0].timeout(), - std::time::Duration::from_millis(30000) - ); - - // Agent hook - assert_eq!(cfg.hooks[1].event, fabro_hooks::HookEvent::RunComplete); - assert!(matches!( - cfg.hooks[1].resolved_hook_type().as_deref(), - Some(fabro_hooks::HookType::Agent { prompt, model, max_tool_rounds }) - if prompt == "Verify all tests pass." - && *model == Some("sonnet".into()) - && *max_tool_rounds == Some(10) - )); - assert_eq!( - cfg.hooks[1].timeout(), - std::time::Duration::from_millis(120000) - ); -} +// Prompt and Agent hook TOML parsing: the legacy `Settings`-based +// variant of this test was deleted in Stage 6.3b; v2 coverage lives in +// `fabro-types::settings::tree::tests`. // --- Events emitted correctly alongside hooks --- @@ -8437,7 +8358,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8637,7 +8558,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8840,7 +8761,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8927,7 +8848,7 @@ async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9014,7 +8935,7 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9144,7 +9065,7 @@ async fn node_dir_uses_visit_count_on_revisit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10013,7 +9934,7 @@ async fn full_pipeline_with_cli_backend_node() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10131,7 +10052,7 @@ async fn stylesheet_backend_property_routes_to_cli() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10321,7 +10242,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-docker"), @@ -10487,7 +10408,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id.to_string()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10684,7 +10605,7 @@ async fn parallel_git_branching_host_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10933,7 +10854,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("empty-diff"), @@ -11300,7 +11221,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-circuit-breaker"), @@ -11346,7 +11267,7 @@ async fn e2e_circuit_breaker_custom_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-custom-limit"), @@ -11385,7 +11306,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-transient-no-breaker"), @@ -11431,7 +11352,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-varying-reasons"), @@ -11470,7 +11391,7 @@ async fn e2e_circuit_breaker_loop_restart() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-breaker"), @@ -11531,7 +11452,7 @@ async fn e2e_failure_signature_persisted_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-context"), @@ -11594,7 +11515,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-hint"), @@ -11649,7 +11570,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-persist"), @@ -11775,7 +11696,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-events"), @@ -11839,7 +11760,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-below-limit"), @@ -11934,7 +11855,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-impl-verify-cycle"), @@ -12030,7 +11951,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-det"), @@ -12069,7 +11990,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-struct"), @@ -12108,7 +12029,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-budget"), @@ -12147,7 +12068,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-canceled"), @@ -12183,7 +12104,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-comploop"), @@ -12223,7 +12144,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-allowed-transient"), @@ -12330,7 +12251,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-e2e"), @@ -12385,7 +12306,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-alive-e2e"), @@ -12430,7 +12351,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-disabled-e2e"), @@ -12494,7 +12415,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-override-e2e"), @@ -12624,11 +12545,14 @@ async fn asset_collection_local_sandbox_success() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12753,11 +12677,14 @@ async fn asset_collection_local_sandbox_on_failure() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12854,11 +12781,14 @@ async fn asset_collection_docker_sandbox() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12927,7 +12857,7 @@ async fn wait_timer_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 500b36498..7d9b4f276 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -22,22 +22,18 @@ models/aggregate-billing-totals.ts models/aggregate-billing.ts models/api-question-option.ts models/api-question.ts -models/api-settings.ts models/append-event-response.ts models/artifact-batch-upload-entry.ts models/artifact-batch-upload-manifest.ts models/artifact-entry.ts models/artifact-list-response.ts -models/artifacts-settings.ts models/assistant-stage-turn.ts -models/auth-settings.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-stage-ref.ts models/board-column.ts models/check-run-status.ts models/check-run.ts -models/checkpoint-settings.ts models/code-location.ts models/completion-content-part.ts models/completion-message.ts @@ -46,10 +42,6 @@ models/completion-tool-choice.ts models/completion-tool-definition.ts models/completion-usage.ts models/create-completion-request.ts -models/daytona-settings-network-one-of.ts -models/daytona-settings-network.ts -models/daytona-settings.ts -models/daytona-snapshot-settings.ts models/diagnostics-check.ts models/diagnostics-detail.ts models/diagnostics-report.ts @@ -66,21 +58,13 @@ models/event-seq.ts models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts models/execute-query-response.ts -models/features.ts models/file-checkpoint.ts models/file-diff.ts -models/git-author-settings.ts -models/git-hub-settings.ts -models/git-settings.ts models/health-response.ts models/history-entry.ts -models/hook-definition.ts models/index.ts models/internal-run-status.ts models/internal-stage-status.ts -models/llm-settings.ts -models/local-sandbox-settings.ts -models/log-settings.ts models/manifest-args.ts models/manifest-config.ts models/manifest-file-entry.ts @@ -90,7 +74,6 @@ models/manifest-goal.ts models/manifest-target.ts models/manifest-workflow-config.ts models/manifest-workflow.ts -models/mcp-server-entry.ts models/model-costs.ts models/model-features.ts models/model-limits.ts @@ -121,7 +104,6 @@ models/preview-url-response.ts models/prune-run-entry.ts models/prune-runs-request.ts models/prune-runs-response.ts -models/pull-request-settings.ts models/question-type.ts models/render-workflow-graph-direction.ts models/render-workflow-graph-format.ts @@ -148,7 +130,6 @@ models/run-pull-request.ts models/run-question.ts models/run-reference.ts models/run-sandbox.ts -models/run-settings.ts models/run-stage.ts models/run-status-record.ts models/run-status-response.ts @@ -157,18 +138,11 @@ models/run-timings.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts models/sandbox-resources.ts -models/sandbox-settings.ts models/save-query-request.ts models/saved-query.ts models/secret-list-response.ts models/secret-metadata.ts -models/server-settings-exec.ts -models/server-settings-fabro.ts -models/server-settings-server-tls.ts -models/server-settings-server.ts -models/server-settings.ts models/set-secret-request.ts -models/setup-settings.ts models/ssh-access-request.ts models/ssh-access-response.ts models/stage-status.ts @@ -180,12 +154,9 @@ models/submit-answer-request.ts models/system-info-response.ts models/system-run-counts.ts models/system-stage-turn.ts -models/tls-settings.ts models/tool-stage-turn.ts models/tool-use.ts models/user-response.ts -models/web-settings.ts -models/webhook-settings.ts models/workflow-diagnostic.ts models/workflow-reference.ts models/write-blob-response.ts diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index a23b543a9..d26751ead 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -42,8 +42,6 @@ import type { RunEvent } from '../models'; // @ts-ignore import type { RunProjection } from '../models'; // @ts-ignore -import type { RunSettings } from '../models'; -// @ts-ignore import type { WriteBlobResponse } from '../models'; // @ts-ignore import type { WriteRunBlobRequest } from '../models'; @@ -896,7 +894,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunSettings(id, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.retrieveRunSettings']?.[localVarOperationServerIndex]?.url; @@ -1068,7 +1066,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { return localVarFp.retrieveRunSettings(id, options).then((request) => request(axios, basePath)); }, /** diff --git a/lib/packages/fabro-api-client/src/api/settings-api.ts b/lib/packages/fabro-api-client/src/api/settings-api.ts index a54b400fd..6b648b707 100644 --- a/lib/packages/fabro-api-client/src/api/settings-api.ts +++ b/lib/packages/fabro-api-client/src/api/settings-api.ts @@ -21,8 +21,6 @@ import globalAxios from 'axios'; import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; -// @ts-ignore -import type { ServerSettings } from '../models'; /** * SettingsApi - axios parameter creator */ @@ -80,7 +78,7 @@ export const SettingsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async retrieveServerSettings(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async retrieveServerSettings(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveServerSettings(options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['SettingsApi.retrieveServerSettings']?.[localVarOperationServerIndex]?.url; @@ -101,7 +99,7 @@ export const SettingsApiFactory = function (configuration?: Configuration, baseP * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveServerSettings(options?: RawAxiosRequestConfig): AxiosPromise { + retrieveServerSettings(options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { return localVarFp.retrieveServerSettings(options).then((request) => request(axios, basePath)); }, }; diff --git a/lib/packages/fabro-api-client/src/models/api-configuration.ts b/lib/packages/fabro-api-client/src/models/api-configuration.ts deleted file mode 100644 index fea66e476..000000000 --- a/lib/packages/fabro-api-client/src/models/api-configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { TlsSettings } from './tls-configuration'; - -/** - * API server configuration. - */ -export interface ApiSettings { - /** - * API base URL. - */ - 'base_url'?: string; - /** - * Authentication strategies. - */ - 'authentication_strategies'?: Array; - 'tls'?: TlsSettings; -} - -export const ApiSettingsAuthenticationStrategiesEnum = { - JWT: 'jwt', - MTLS: 'mtls' -} as const; - -export type ApiSettingsAuthenticationStrategiesEnum = typeof ApiSettingsAuthenticationStrategiesEnum[keyof typeof ApiSettingsAuthenticationStrategiesEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/api-settings.ts b/lib/packages/fabro-api-client/src/models/api-settings.ts deleted file mode 100644 index ed5b34002..000000000 --- a/lib/packages/fabro-api-client/src/models/api-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { TlsSettings } from './tls-settings'; - -/** - * API server configuration. - */ -export interface ApiSettings { - /** - * API base URL. - */ - 'base_url'?: string; - /** - * Authentication strategies. - */ - 'authentication_strategies'?: Array; - 'tls'?: TlsSettings; -} - -export const ApiSettingsAuthenticationStrategiesEnum = { - JWT: 'jwt', - MTLS: 'mtls' -} as const; - -export type ApiSettingsAuthenticationStrategiesEnum = typeof ApiSettingsAuthenticationStrategiesEnum[keyof typeof ApiSettingsAuthenticationStrategiesEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts b/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts deleted file mode 100644 index a1acd2ae9..000000000 --- a/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Artifact collection configuration. - */ -export interface ArtifactsSettings { - /** - * Glob patterns for files to collect as run artifacts. - */ - 'include'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/artifacts-settings.ts b/lib/packages/fabro-api-client/src/models/artifacts-settings.ts deleted file mode 100644 index a1acd2ae9..000000000 --- a/lib/packages/fabro-api-client/src/models/artifacts-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Artifact collection configuration. - */ -export interface ArtifactsSettings { - /** - * Glob patterns for files to collect as run artifacts. - */ - 'include'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/auth-configuration.ts b/lib/packages/fabro-api-client/src/models/auth-configuration.ts deleted file mode 100644 index d7763c463..000000000 --- a/lib/packages/fabro-api-client/src/models/auth-configuration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Authentication configuration. - */ -export interface AuthSettings { - /** - * Auth provider. - */ - 'provider'?: AuthSettingsProviderEnum; - /** - * Allowed usernames. - */ - 'allowed_usernames'?: Array; -} - -export const AuthSettingsProviderEnum = { - GITHUB: 'github', - INSECURE_DISABLED: 'insecure_disabled' -} as const; - -export type AuthSettingsProviderEnum = typeof AuthSettingsProviderEnum[keyof typeof AuthSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/auth-settings.ts b/lib/packages/fabro-api-client/src/models/auth-settings.ts deleted file mode 100644 index d7763c463..000000000 --- a/lib/packages/fabro-api-client/src/models/auth-settings.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Authentication configuration. - */ -export interface AuthSettings { - /** - * Auth provider. - */ - 'provider'?: AuthSettingsProviderEnum; - /** - * Allowed usernames. - */ - 'allowed_usernames'?: Array; -} - -export const AuthSettingsProviderEnum = { - GITHUB: 'github', - INSECURE_DISABLED: 'insecure_disabled' -} as const; - -export type AuthSettingsProviderEnum = typeof AuthSettingsProviderEnum[keyof typeof AuthSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts b/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts deleted file mode 100644 index b16f61b44..000000000 --- a/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Checkpoint configuration for file exclusion. - */ -export interface CheckpointSettings { - /** - * Glob patterns to exclude from checkpoints. - */ - 'exclude_globs'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts b/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts deleted file mode 100644 index b16f61b44..000000000 --- a/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Checkpoint configuration for file exclusion. - */ -export interface CheckpointSettings { - /** - * Glob patterns to exclude from checkpoints. - */ - 'exclude_globs'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/create-run-request.ts b/lib/packages/fabro-api-client/src/models/create-run-request.ts deleted file mode 100644 index efd73b116..000000000 --- a/lib/packages/fabro-api-client/src/models/create-run-request.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Request body for creating a new run, either from inline Graphviz source or from a local workflow path plus resolved settings. - */ -export interface CreateRunRequest { - /** - * Graphviz DOT language source defining the workflow graph. - */ - 'dot_source'?: string; - /** - * Absolute or relative path to the workflow file to load on the local machine. - */ - 'workflow_path'?: string; - /** - * Working directory used to resolve the workflow path. - */ - 'cwd'?: string; - /** - * JSON-serialized `fabro_types::Settings` payload resolved by the CLI. - */ - 'settings_json'?: string; - /** - * Optional pre-generated run ID to use instead of allocating a new ULID. - */ - 'run_id'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts deleted file mode 100644 index c19be5122..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface DaytonaSettingsNetworkOneOf { - /** - * CIDR allowlist for network access. - */ - 'allow_list': Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts deleted file mode 100644 index c6a63aac3..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetworkOneOf } from './daytona-configuration-network-one-of'; - -/** - * @type DaytonaSettingsNetwork - * Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}. - */ -export type DaytonaSettingsNetwork = DaytonaSettingsNetworkOneOf | string; - - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration.ts deleted file mode 100644 index 88253d4a6..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetwork } from './daytona-configuration-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSnapshotSettings } from './daytona-snapshot-configuration'; - -/** - * Daytona-specific sandbox settings. - */ -export interface DaytonaSettings { - /** - * Auto-stop interval in seconds. - */ - 'auto_stop_interval'?: number; - /** - * Labels applied to the sandbox. - */ - 'labels'?: { [key: string]: string; }; - 'snapshot'?: DaytonaSnapshotSettings; - 'network'?: DaytonaSettingsNetwork; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts b/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts deleted file mode 100644 index c19be5122..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface DaytonaSettingsNetworkOneOf { - /** - * CIDR allowlist for network access. - */ - 'allow_list': Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts b/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts deleted file mode 100644 index faa31fc2c..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetworkOneOf } from './daytona-settings-network-one-of'; - -/** - * @type DaytonaSettingsNetwork - * Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}. - */ -export type DaytonaSettingsNetwork = DaytonaSettingsNetworkOneOf | string; - - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-settings.ts deleted file mode 100644 index 5738b17fa..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetwork } from './daytona-settings-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSnapshotSettings } from './daytona-snapshot-settings'; - -/** - * Daytona-specific sandbox settings. - */ -export interface DaytonaSettings { - /** - * Auto-stop interval in seconds. - */ - 'auto_stop_interval'?: number; - /** - * Labels applied to the sandbox. - */ - 'labels'?: { [key: string]: string; }; - 'snapshot'?: DaytonaSnapshotSettings; - 'network'?: DaytonaSettingsNetwork; - /** - * Skip git repo detection and cloning during initialization. - */ - 'skip_clone'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts b/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts deleted file mode 100644 index afb9fc7e2..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Snapshot configuration for Daytona sandboxes. - */ -export interface DaytonaSnapshotSettings { - /** - * Snapshot name. - */ - 'name': string; - /** - * CPU cores. - */ - 'cpu'?: number; - /** - * Memory in GB. - */ - 'memory'?: number; - /** - * Disk in GB. - */ - 'disk'?: number; - /** - * Dockerfile content for snapshot creation. - */ - 'dockerfile'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts deleted file mode 100644 index afb9fc7e2..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Snapshot configuration for Daytona sandboxes. - */ -export interface DaytonaSnapshotSettings { - /** - * Snapshot name. - */ - 'name': string; - /** - * CPU cores. - */ - 'cpu'?: number; - /** - * Memory in GB. - */ - 'memory'?: number; - /** - * Disk in GB. - */ - 'disk'?: number; - /** - * Dockerfile content for snapshot creation. - */ - 'dockerfile'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/exe-configuration.ts b/lib/packages/fabro-api-client/src/models/exe-configuration.ts deleted file mode 100644 index a214b0237..000000000 --- a/lib/packages/fabro-api-client/src/models/exe-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * exe.dev sandbox configuration. - */ -export interface ExeSettings { - /** - * VM image to use for the exe.dev sandbox. - */ - 'image'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/exe-settings.ts b/lib/packages/fabro-api-client/src/models/exe-settings.ts deleted file mode 100644 index a214b0237..000000000 --- a/lib/packages/fabro-api-client/src/models/exe-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * exe.dev sandbox configuration. - */ -export interface ExeSettings { - /** - * VM image to use for the exe.dev sandbox. - */ - 'image'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/features.ts b/lib/packages/fabro-api-client/src/models/features.ts deleted file mode 100644 index ee3916a3e..000000000 --- a/lib/packages/fabro-api-client/src/models/features.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Feature flags. - */ -export interface Features { - /** - * Enable session sandboxes. - */ - 'session_sandboxes'?: boolean; - /** - * Experimental: enable automatic retro generation after workflow runs. - */ - 'retros'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-author-configuration.ts b/lib/packages/fabro-api-client/src/models/git-author-configuration.ts deleted file mode 100644 index aaae0960d..000000000 --- a/lib/packages/fabro-api-client/src/models/git-author-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Git commit author configuration. - */ -export interface GitAuthorSettings { - /** - * Author name for commits. - */ - 'name'?: string; - /** - * Author email for commits. - */ - 'email'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-author-settings.ts b/lib/packages/fabro-api-client/src/models/git-author-settings.ts deleted file mode 100644 index aaae0960d..000000000 --- a/lib/packages/fabro-api-client/src/models/git-author-settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Git commit author configuration. - */ -export interface GitAuthorSettings { - /** - * Author name for commits. - */ - 'name'?: string; - /** - * Author email for commits. - */ - 'email'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-configuration.ts b/lib/packages/fabro-api-client/src/models/git-configuration.ts deleted file mode 100644 index ce47af29e..000000000 --- a/lib/packages/fabro-api-client/src/models/git-configuration.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { GitAuthorSettings } from './git-author-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebhookSettings } from './webhook-configuration'; - -/** - * Git provider configuration. - */ -export interface GitSettings { - /** - * Git provider. - */ - 'provider'?: GitSettingsProviderEnum; - /** - * GitHub App ID. - */ - 'app_id'?: string; - /** - * GitHub App Client ID. - */ - 'client_id'?: string; - /** - * GitHub App slug. - */ - 'slug'?: string; - 'author'?: GitAuthorSettings; - 'webhooks'?: WebhookSettings; -} - -export const GitSettingsProviderEnum = { - GITHUB: 'github' -} as const; - -export type GitSettingsProviderEnum = typeof GitSettingsProviderEnum[keyof typeof GitSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts b/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts deleted file mode 100644 index b0f56b6c9..000000000 --- a/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * GitHub App token injection configuration. - */ -export interface GitHubSettings { - /** - * GitHub API permissions to request (e.g. contents = write). - */ - 'permissions'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-hub-settings.ts b/lib/packages/fabro-api-client/src/models/git-hub-settings.ts deleted file mode 100644 index b0f56b6c9..000000000 --- a/lib/packages/fabro-api-client/src/models/git-hub-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * GitHub App token injection configuration. - */ -export interface GitHubSettings { - /** - * GitHub API permissions to request (e.g. contents = write). - */ - 'permissions'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-settings.ts b/lib/packages/fabro-api-client/src/models/git-settings.ts deleted file mode 100644 index a37da07ba..000000000 --- a/lib/packages/fabro-api-client/src/models/git-settings.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { GitAuthorSettings } from './git-author-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebhookSettings } from './webhook-settings'; - -/** - * Git provider configuration. - */ -export interface GitSettings { - /** - * Git provider. - */ - 'provider'?: GitSettingsProviderEnum; - /** - * GitHub App ID. - */ - 'app_id'?: string; - /** - * GitHub App Client ID. - */ - 'client_id'?: string; - /** - * GitHub App slug. - */ - 'slug'?: string; - 'author'?: GitAuthorSettings; - 'webhooks'?: WebhookSettings; -} - -export const GitSettingsProviderEnum = { - GITHUB: 'github' -} as const; - -export type GitSettingsProviderEnum = typeof GitSettingsProviderEnum[keyof typeof GitSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/hook-definition.ts b/lib/packages/fabro-api-client/src/models/hook-definition.ts deleted file mode 100644 index b0717999b..000000000 --- a/lib/packages/fabro-api-client/src/models/hook-definition.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * 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. - */ -export interface HookDefinition { - /** - * Human-readable hook name. - */ - 'name'?: string; - /** - * Event that triggers this hook. - */ - 'event': HookDefinitionEventEnum; - /** - * Shell command (shorthand for type=command). - */ - 'command'?: string; - /** - * Hook execution type. - */ - 'type'?: HookDefinitionTypeEnum; - /** - * URL for HTTP hooks. - */ - 'url'?: string; - /** - * Headers for HTTP hooks. - */ - 'headers'?: { [key: string]: string; }; - /** - * Environment variables allowed in HTTP hook headers. - */ - 'allowed_env_vars'?: Array; - /** - * TLS verification mode for HTTP hooks. - */ - 'tls'?: HookDefinitionTlsEnum; - /** - * Prompt text for prompt/agent hooks. - */ - 'prompt'?: string; - /** - * Model for prompt/agent hooks. - */ - 'model'?: string; - /** - * Max tool rounds for agent hooks. - */ - 'max_tool_rounds'?: number; - /** - * Regex matched against node_id or handler_type. - */ - 'matcher'?: string; - /** - * Whether this hook blocks execution. - */ - 'blocking'?: boolean; - /** - * Timeout in milliseconds. - */ - 'timeout_ms'?: number; - /** - * Whether hook runs in sandbox. - */ - 'sandbox'?: boolean; -} - -export const HookDefinitionEventEnum = { - RUN_START: 'run_start', - RUN_COMPLETE: 'run_complete', - STAGE_START: 'stage_start', - STAGE_COMPLETE: 'stage_complete' -} as const; - -export type HookDefinitionEventEnum = typeof HookDefinitionEventEnum[keyof typeof HookDefinitionEventEnum]; -export const HookDefinitionTypeEnum = { - COMMAND: 'command', - HTTP: 'http', - PROMPT: 'prompt', - AGENT: 'agent' -} as const; - -export type HookDefinitionTypeEnum = typeof HookDefinitionTypeEnum[keyof typeof HookDefinitionTypeEnum]; -export const HookDefinitionTlsEnum = { - VERIFY: 'verify', - NO_VERIFY: 'no_verify', - OFF: 'off' -} as const; - -export type HookDefinitionTlsEnum = typeof HookDefinitionTlsEnum[keyof typeof HookDefinitionTlsEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 83ec5eb72..eade91d3c 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -4,22 +4,18 @@ export * from './aggregate-billing'; export * from './aggregate-billing-totals'; export * from './api-question'; export * from './api-question-option'; -export * from './api-settings'; export * from './append-event-response'; export * from './artifact-batch-upload-entry'; export * from './artifact-batch-upload-manifest'; export * from './artifact-entry'; export * from './artifact-list-response'; -export * from './artifacts-settings'; export * from './assistant-stage-turn'; -export * from './auth-settings'; export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-stage-ref'; export * from './board-column'; export * from './check-run'; export * from './check-run-status'; -export * from './checkpoint-settings'; export * from './code-location'; export * from './completion-content-part'; export * from './completion-message'; @@ -28,10 +24,6 @@ export * from './completion-tool-choice'; export * from './completion-tool-definition'; export * from './completion-usage'; export * from './create-completion-request'; -export * from './daytona-settings'; -export * from './daytona-settings-network'; -export * from './daytona-settings-network-one-of'; -export * from './daytona-snapshot-settings'; export * from './diagnostics-check'; export * from './diagnostics-detail'; export * from './diagnostics-report'; @@ -48,20 +40,12 @@ export * from './event-seq'; export * from './execute-query-request'; export * from './execute-query-response'; export * from './execute-query-response-rows-inner-inner'; -export * from './features'; export * from './file-checkpoint'; export * from './file-diff'; -export * from './git-author-settings'; -export * from './git-hub-settings'; -export * from './git-settings'; export * from './health-response'; export * from './history-entry'; -export * from './hook-definition'; export * from './internal-run-status'; export * from './internal-stage-status'; -export * from './llm-settings'; -export * from './local-sandbox-settings'; -export * from './log-settings'; export * from './manifest-args'; export * from './manifest-config'; export * from './manifest-file-entry'; @@ -71,7 +55,6 @@ export * from './manifest-goal'; export * from './manifest-target'; export * from './manifest-workflow'; export * from './manifest-workflow-config'; -export * from './mcp-server-entry'; export * from './model'; export * from './model-costs'; export * from './model-features'; @@ -102,7 +85,6 @@ export * from './preview-url-response'; export * from './prune-run-entry'; export * from './prune-runs-request'; export * from './prune-runs-response'; -export * from './pull-request-settings'; export * from './question-type'; export * from './render-workflow-graph-direction'; export * from './render-workflow-graph-format'; @@ -129,7 +111,6 @@ export * from './run-pull-request'; export * from './run-question'; export * from './run-reference'; export * from './run-sandbox'; -export * from './run-settings'; export * from './run-stage'; export * from './run-status'; export * from './run-status-record'; @@ -138,18 +119,11 @@ export * from './run-timings'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; export * from './sandbox-resources'; -export * from './sandbox-settings'; export * from './save-query-request'; export * from './saved-query'; export * from './secret-list-response'; export * from './secret-metadata'; -export * from './server-settings'; -export * from './server-settings-exec'; -export * from './server-settings-fabro'; -export * from './server-settings-server'; -export * from './server-settings-server-tls'; export * from './set-secret-request'; -export * from './setup-settings'; export * from './ssh-access-request'; export * from './ssh-access-response'; export * from './stage-status'; @@ -161,12 +135,9 @@ export * from './submit-answer-request'; export * from './system-info-response'; export * from './system-run-counts'; export * from './system-stage-turn'; -export * from './tls-settings'; export * from './tool-stage-turn'; export * from './tool-use'; export * from './user-response'; -export * from './web-settings'; -export * from './webhook-settings'; export * from './workflow-diagnostic'; export * from './workflow-reference'; export * from './write-blob-response'; diff --git a/lib/packages/fabro-api-client/src/models/llm-configuration.ts b/lib/packages/fabro-api-client/src/models/llm-configuration.ts deleted file mode 100644 index e2ec24400..000000000 --- a/lib/packages/fabro-api-client/src/models/llm-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * LLM provider and model settings. - */ -export interface LlmSettings { - /** - * Model identifier. - */ - 'model'?: string; - /** - * Provider name. - */ - 'provider'?: string; - /** - * Provider fallback chains. - */ - 'fallbacks'?: { [key: string]: Array; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/llm-settings.ts b/lib/packages/fabro-api-client/src/models/llm-settings.ts deleted file mode 100644 index e2ec24400..000000000 --- a/lib/packages/fabro-api-client/src/models/llm-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * LLM provider and model settings. - */ -export interface LlmSettings { - /** - * Model identifier. - */ - 'model'?: string; - /** - * Provider name. - */ - 'provider'?: string; - /** - * Provider fallback chains. - */ - 'fallbacks'?: { [key: string]: Array; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts b/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts deleted file mode 100644 index dca9c61d4..000000000 --- a/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Local sandbox settings. - */ -export interface LocalSandboxSettings { - /** - * Git worktree mode for local sandbox. - */ - 'worktree_mode'?: LocalSandboxSettingsWorktreeModeEnum; -} - -export const LocalSandboxSettingsWorktreeModeEnum = { - ALWAYS: 'always', - CLEAN: 'clean', - DIRTY: 'dirty', - NEVER: 'never' -} as const; - -export type LocalSandboxSettingsWorktreeModeEnum = typeof LocalSandboxSettingsWorktreeModeEnum[keyof typeof LocalSandboxSettingsWorktreeModeEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts deleted file mode 100644 index dca9c61d4..000000000 --- a/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Local sandbox settings. - */ -export interface LocalSandboxSettings { - /** - * Git worktree mode for local sandbox. - */ - 'worktree_mode'?: LocalSandboxSettingsWorktreeModeEnum; -} - -export const LocalSandboxSettingsWorktreeModeEnum = { - ALWAYS: 'always', - CLEAN: 'clean', - DIRTY: 'dirty', - NEVER: 'never' -} as const; - -export type LocalSandboxSettingsWorktreeModeEnum = typeof LocalSandboxSettingsWorktreeModeEnum[keyof typeof LocalSandboxSettingsWorktreeModeEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/log-configuration.ts b/lib/packages/fabro-api-client/src/models/log-configuration.ts deleted file mode 100644 index f973459a3..000000000 --- a/lib/packages/fabro-api-client/src/models/log-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Logging configuration. - */ -export interface LogSettings { - /** - * Log level (e.g. trace, debug, info). - */ - 'level'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/log-settings.ts b/lib/packages/fabro-api-client/src/models/log-settings.ts deleted file mode 100644 index f973459a3..000000000 --- a/lib/packages/fabro-api-client/src/models/log-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Logging configuration. - */ -export interface LogSettings { - /** - * Log level (e.g. trace, debug, info). - */ - 'level'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts b/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts deleted file mode 100644 index f011cdf71..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * MCP server connection entry. - */ -export interface McpServerEntry { - /** - * Transport type (stdio or http). - */ - 'type'?: string; - /** - * Command and arguments for stdio transport. - */ - 'command'?: Array; - /** - * Environment variables for stdio transport. - */ - 'env'?: { [key: string]: string; }; - /** - * URL for http transport. - */ - 'url'?: string; - /** - * HTTP headers for http transport. - */ - 'headers'?: { [key: string]: string; }; - /** - * Startup timeout in seconds. - */ - 'startup_timeout_secs'?: number; - /** - * Tool call timeout in seconds. - */ - 'tool_timeout_secs'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts b/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts deleted file mode 100644 index 4d44d5718..000000000 --- a/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Pull request creation configuration. - */ -export interface PullRequestSettings { - /** - * Whether to create a pull request after a successful run. - */ - 'enabled'?: boolean; - /** - * Whether to create the pull request as a draft. - */ - 'draft'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/pull-request-settings.ts b/lib/packages/fabro-api-client/src/models/pull-request-settings.ts deleted file mode 100644 index 548fa471b..000000000 --- a/lib/packages/fabro-api-client/src/models/pull-request-settings.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Pull request creation configuration. - */ -export interface PullRequestSettings { - /** - * Whether to create a pull request after a successful run. - */ - 'enabled'?: boolean; - /** - * Whether to create the pull request as a draft. - */ - 'draft'?: boolean; - /** - * Whether to enable GitHub auto-merge on the created PR. Implies draft = false. - */ - 'auto_merge'?: boolean; - /** - * Merge strategy for auto-merge. - */ - 'merge_strategy'?: PullRequestSettingsMergeStrategyEnum; -} - -export const PullRequestSettingsMergeStrategyEnum = { - SQUASH: 'squash', - MERGE: 'merge', - REBASE: 'rebase' -} as const; - -export type PullRequestSettingsMergeStrategyEnum = typeof PullRequestSettingsMergeStrategyEnum[keyof typeof PullRequestSettingsMergeStrategyEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/run-configuration.ts b/lib/packages/fabro-api-client/src/models/run-configuration.ts deleted file mode 100644 index 95be5f730..000000000 --- a/lib/packages/fabro-api-client/src/models/run-configuration.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-configuration'; - -/** - * Structured run settings mirroring FabroSettings. - */ -export interface RunSettings { - /** - * Settings schema version. - */ - 'version': number; - /** - * Goal description for the run. - */ - 'goal'?: string; - /** - * DOT graph filename. - */ - 'graph': string; - /** - * Working directory for the run. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Variable map for template expansion. - */ - 'vars'?: { [key: string]: string; }; - 'hooks'?: Array; -} diff --git a/lib/packages/fabro-api-client/src/models/run-settings.ts b/lib/packages/fabro-api-client/src/models/run-settings.ts deleted file mode 100644 index 37e72d7a2..000000000 --- a/lib/packages/fabro-api-client/src/models/run-settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-settings'; - -/** - * Structured run settings mirroring fabro_types::Settings. - */ -export interface RunSettings { - /** - * Settings schema version. - */ - 'version': number; - /** - * Goal description for the run. - */ - 'goal'?: string; - /** - * Graphviz graph filename. - */ - 'graph': string; - /** - * Working directory for the run. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Variable map for template expansion. - */ - 'vars'?: { [key: string]: string; }; - 'hooks'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts b/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts deleted file mode 100644 index 89394cc02..000000000 --- a/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettings } from './daytona-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { ExeSettings } from './exe-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { LocalSandboxSettings } from './local-sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SshSettings } from './ssh-configuration'; - -/** - * Sandbox execution environment settings. - */ -export interface SandboxSettings { - /** - * Sandbox provider name. - */ - 'provider'?: string; - /** - * Whether to preserve the sandbox after the run. - */ - 'preserve'?: boolean; - /** - * Whether to use a devcontainer for the sandbox. - */ - 'devcontainer'?: boolean; - 'daytona'?: DaytonaSettings; - 'exe'?: ExeSettings; - 'ssh'?: SshSettings; - 'local'?: LocalSandboxSettings; - /** - * Environment variables injected into the sandbox. - */ - 'env'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/sandbox-settings.ts deleted file mode 100644 index ed50c1853..000000000 --- a/lib/packages/fabro-api-client/src/models/sandbox-settings.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettings } from './daytona-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { LocalSandboxSettings } from './local-sandbox-settings'; - -/** - * Sandbox execution environment settings. - */ -export interface SandboxSettings { - /** - * Sandbox provider name. - */ - 'provider'?: string; - /** - * Whether to preserve the sandbox after the run. - */ - 'preserve'?: boolean; - /** - * Whether to use a devcontainer for the sandbox. - */ - 'devcontainer'?: boolean; - 'daytona'?: DaytonaSettings; - 'local'?: LocalSandboxSettings; - /** - * Environment variables injected into the sandbox. - */ - 'env'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-configuration.ts b/lib/packages/fabro-api-client/src/models/server-configuration.ts deleted file mode 100644 index b574c9d81..000000000 --- a/lib/packages/fabro-api-client/src/models/server-configuration.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ApiSettings } from './api-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { ArtifactsSettings } from './artifacts-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { CheckpointSettings } from './checkpoint-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { Features } from './features'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitSettings } from './git-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitHubSettings } from './git-hub-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { LogSettings } from './log-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerEntry } from './mcp-server-entry'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequestSettings } from './pull-request-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebSettings } from './web-configuration'; - -/** - * Structured server settings mirroring FabroSettings. - */ -export interface ServerSettings { - /** - * Data directory path. - */ - 'data_dir'?: string; - /** - * Maximum concurrent runs. - */ - 'max_concurrent_runs'?: number; - 'web'?: WebSettings; - 'api'?: ApiSettings; - 'git'?: GitSettings; - 'features'?: Features; - 'log'?: LogSettings; - /** - * Default working directory. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Default variable map. - */ - 'vars'?: { [key: string]: string; }; - 'checkpoint'?: CheckpointSettings; - 'pull_request'?: PullRequestSettings; - 'hooks'?: Array; - 'artifacts'?: ArtifactsSettings; - /** - * Default MCP server configurations. - */ - 'mcp_servers'?: { [key: string]: McpServerEntry; }; - 'github'?: GitHubSettings; -} diff --git a/lib/packages/fabro-api-client/src/models/server-settings-exec.ts b/lib/packages/fabro-api-client/src/models/server-settings-exec.ts deleted file mode 100644 index 905b9864a..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-exec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsExec { - /** - * Default exec provider. - */ - 'provider'?: string; - /** - * Default exec model. - */ - 'model'?: string; - /** - * Exec permission level. - */ - 'permissions'?: ServerSettingsExecPermissionsEnum; - /** - * Exec output format. - */ - 'output_format'?: ServerSettingsExecOutputFormatEnum; -} - -export const ServerSettingsExecPermissionsEnum = { - READ_ONLY: 'read-only', - READ_WRITE: 'read-write', - FULL: 'full' -} as const; - -export type ServerSettingsExecPermissionsEnum = typeof ServerSettingsExecPermissionsEnum[keyof typeof ServerSettingsExecPermissionsEnum]; -export const ServerSettingsExecOutputFormatEnum = { - TEXT: 'text', - JSON: 'json' -} as const; - -export type ServerSettingsExecOutputFormatEnum = typeof ServerSettingsExecOutputFormatEnum[keyof typeof ServerSettingsExecOutputFormatEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts b/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts deleted file mode 100644 index 4760e3096..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsFabro { - /** - * Project fabro root directory. - */ - 'root'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts b/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts deleted file mode 100644 index 497356fa3..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsServerTls { - /** - * Client certificate path. - */ - 'cert'?: string; - /** - * Client key path. - */ - 'key'?: string; - /** - * Certificate authority path. - */ - 'ca'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server.ts b/lib/packages/fabro-api-client/src/models/server-settings-server.ts deleted file mode 100644 index 8371218cb..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-server.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsServerTls } from './server-settings-server-tls'; - -export interface ServerSettingsServer { - /** - * Default server target for CLI commands. - */ - 'target'?: string; - 'tls'?: ServerSettingsServerTls; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings.ts b/lib/packages/fabro-api-client/src/models/server-settings.ts deleted file mode 100644 index efc03d017..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ApiSettings } from './api-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { ArtifactsSettings } from './artifacts-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { CheckpointSettings } from './checkpoint-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { Features } from './features'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitHubSettings } from './git-hub-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitSettings } from './git-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { LogSettings } from './log-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerEntry } from './mcp-server-entry'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequestSettings } from './pull-request-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsExec } from './server-settings-exec'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsFabro } from './server-settings-fabro'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsServer } from './server-settings-server'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebSettings } from './web-settings'; - -/** - * Structured server settings mirroring fabro_types::Settings. - */ -export interface ServerSettings { - /** - * Settings schema version. - */ - 'version'?: number; - /** - * Default goal description. - */ - 'goal'?: string; - /** - * Path to a goal file. - */ - 'goal_file'?: string; - /** - * Default Graphviz graph path. - */ - 'graph'?: string; - /** - * Default label map. - */ - 'labels'?: { [key: string]: string; }; - 'server'?: ServerSettingsServer; - 'exec'?: ServerSettingsExec; - /** - * Prevent system idle sleep while running. - */ - 'prevent_idle_sleep'?: boolean; - /** - * Enable verbose output by default. - */ - 'verbose'?: boolean; - /** - * Whether upgrade checks are enabled. - */ - 'upgrade_check'?: boolean; - /** - * Default dry-run mode. - */ - 'dry_run'?: boolean; - /** - * Default auto-approve mode. - */ - 'auto_approve'?: boolean; - /** - * Skip retro generation by default. - */ - 'no_retro'?: boolean; - /** - * Storage directory path. - */ - 'storage_dir'?: string; - /** - * Maximum concurrent runs. - */ - 'max_concurrent_runs'?: number; - 'web'?: WebSettings; - 'api'?: ApiSettings; - 'git'?: GitSettings; - 'features'?: Features; - 'log'?: LogSettings; - /** - * Default working directory. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Default variable map. - */ - 'vars'?: { [key: string]: string; }; - 'checkpoint'?: CheckpointSettings; - 'pull_request'?: PullRequestSettings; - 'hooks'?: Array; - 'artifacts'?: ArtifactsSettings; - /** - * Default MCP server configurations. - */ - 'mcp_servers'?: { [key: string]: McpServerEntry; }; - 'github'?: GitHubSettings; - 'fabro'?: ServerSettingsFabro; -} - diff --git a/lib/packages/fabro-api-client/src/models/setup-configuration.ts b/lib/packages/fabro-api-client/src/models/setup-configuration.ts deleted file mode 100644 index 538cde1c0..000000000 --- a/lib/packages/fabro-api-client/src/models/setup-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Setup commands run before the workflow. - */ -export interface SetupSettings { - /** - * Shell commands to execute. - */ - 'commands': Array; - /** - * Timeout per command in milliseconds. - */ - 'timeout_ms'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/setup-settings.ts b/lib/packages/fabro-api-client/src/models/setup-settings.ts deleted file mode 100644 index 538cde1c0..000000000 --- a/lib/packages/fabro-api-client/src/models/setup-settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Setup commands run before the workflow. - */ -export interface SetupSettings { - /** - * Shell commands to execute. - */ - 'commands': Array; - /** - * Timeout per command in milliseconds. - */ - 'timeout_ms'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/ssh-configuration.ts b/lib/packages/fabro-api-client/src/models/ssh-configuration.ts deleted file mode 100644 index 37fef613a..000000000 --- a/lib/packages/fabro-api-client/src/models/ssh-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * SSH sandbox configuration for user-provided hosts. - */ -export interface SshSettings { - /** - * SSH destination (e.g. user@host or an SSH alias). - */ - 'destination': string; - /** - * Remote working directory. - */ - 'working_directory': string; - /** - * Optional path to a custom SSH config file. - */ - 'config_file'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/ssh-settings.ts b/lib/packages/fabro-api-client/src/models/ssh-settings.ts deleted file mode 100644 index 37fef613a..000000000 --- a/lib/packages/fabro-api-client/src/models/ssh-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * SSH sandbox configuration for user-provided hosts. - */ -export interface SshSettings { - /** - * SSH destination (e.g. user@host or an SSH alias). - */ - 'destination': string; - /** - * Remote working directory. - */ - 'working_directory': string; - /** - * Optional path to a custom SSH config file. - */ - 'config_file'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/tls-configuration.ts b/lib/packages/fabro-api-client/src/models/tls-configuration.ts deleted file mode 100644 index 541cb1245..000000000 --- a/lib/packages/fabro-api-client/src/models/tls-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * TLS certificate configuration. - */ -export interface TlsSettings { - /** - * Certificate file path. - */ - 'cert': string; - /** - * Key file path. - */ - 'key': string; - /** - * CA certificate file path. - */ - 'ca': string; -} - diff --git a/lib/packages/fabro-api-client/src/models/tls-settings.ts b/lib/packages/fabro-api-client/src/models/tls-settings.ts deleted file mode 100644 index 541cb1245..000000000 --- a/lib/packages/fabro-api-client/src/models/tls-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * TLS certificate configuration. - */ -export interface TlsSettings { - /** - * Certificate file path. - */ - 'cert': string; - /** - * Key file path. - */ - 'key': string; - /** - * CA certificate file path. - */ - 'ca': string; -} - diff --git a/lib/packages/fabro-api-client/src/models/web-configuration.ts b/lib/packages/fabro-api-client/src/models/web-configuration.ts deleted file mode 100644 index 973e4af16..000000000 --- a/lib/packages/fabro-api-client/src/models/web-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AuthSettings } from './auth-configuration'; - -/** - * Web UI configuration. - */ -export interface WebSettings { - /** - * Web UI URL. - */ - 'url'?: string; - 'auth'?: AuthSettings; -} - diff --git a/lib/packages/fabro-api-client/src/models/web-settings.ts b/lib/packages/fabro-api-client/src/models/web-settings.ts deleted file mode 100644 index cbe3dee56..000000000 --- a/lib/packages/fabro-api-client/src/models/web-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AuthSettings } from './auth-settings'; - -/** - * Web UI configuration. - */ -export interface WebSettings { - /** - * Whether the embedded web UI and browser-oriented routes are enabled. - */ - 'enabled'?: boolean; - /** - * Web UI URL. - */ - 'url'?: string; - 'auth'?: AuthSettings; -} - diff --git a/lib/packages/fabro-api-client/src/models/webhook-configuration.ts b/lib/packages/fabro-api-client/src/models/webhook-configuration.ts deleted file mode 100644 index 8bd2e9e39..000000000 --- a/lib/packages/fabro-api-client/src/models/webhook-configuration.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Webhook delivery configuration. - */ -export interface WebhookSettings { - /** - * Webhook delivery strategy. - */ - 'strategy': WebhookSettingsStrategyEnum; -} - -export const WebhookSettingsStrategyEnum = { - TAILSCALE_FUNNEL: 'tailscale_funnel' -} as const; - -export type WebhookSettingsStrategyEnum = typeof WebhookSettingsStrategyEnum[keyof typeof WebhookSettingsStrategyEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/webhook-settings.ts b/lib/packages/fabro-api-client/src/models/webhook-settings.ts deleted file mode 100644 index 8bd2e9e39..000000000 --- a/lib/packages/fabro-api-client/src/models/webhook-settings.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Webhook delivery configuration. - */ -export interface WebhookSettings { - /** - * Webhook delivery strategy. - */ - 'strategy': WebhookSettingsStrategyEnum; -} - -export const WebhookSettingsStrategyEnum = { - TAILSCALE_FUNNEL: 'tailscale_funnel' -} as const; - -export type WebhookSettingsStrategyEnum = typeof WebhookSettingsStrategyEnum[keyof typeof WebhookSettingsStrategyEnum]; - -