diff --git a/Cargo.lock b/Cargo.lock index 53f9a20ce..326e2677c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,6 +1585,7 @@ name = "fabro-checkpoint" version = "0.212.0-nightly.0" dependencies = [ "chrono", + "fabro-config", "fabro-store", "fabro-types", "git2", @@ -1722,6 +1723,7 @@ dependencies = [ "chrono", "clap", "dirs", + "fabro-macros", "fabro-proc", "fabro-types", "fabro-util", @@ -2213,7 +2215,6 @@ dependencies = [ "chrono", "clap", "dirs", - "fabro-macros", "fabro-model", "fabro-util", "hex", diff --git a/apps/fabro-web/app/lib/workflow-api.ts b/apps/fabro-web/app/lib/workflow-api.ts index 6ee4ecb97..ac001f674 100644 --- a/apps/fabro-web/app/lib/workflow-api.ts +++ b/apps/fabro-web/app/lib/workflow-api.ts @@ -1,10 +1,10 @@ import type { PaginationMeta } from "@qltysh/fabro-api-client"; /** - * Opaque persisted `SettingsLayer` payload returned by `/api/v1/runs/:id/settings`. - * Treated as a loose JSON object on the web side — consumers only render it. + * Opaque persisted `WorkflowSettings` snapshot returned by `/api/v1/runs/:id/settings`. + * Treated as a loose JSON object on the web side; consumers only render it. */ -export type RunSettingsLayer = Record; +export type WorkflowSettingsSnapshot = Record; export interface WorkflowScheduleSummary { expression: string; @@ -33,6 +33,6 @@ export interface WorkflowDetailResponse { slug: string; description: string; filename: string; - settings: RunSettingsLayer; + settings: WorkflowSettingsSnapshot; graph: string; } diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index e66f4bbeb..ea4d134d6 100644 --- a/apps/fabro-web/app/routes/run-settings.tsx +++ b/apps/fabro-web/app/routes/run-settings.tsx @@ -6,14 +6,14 @@ import { apiJson } from "../api"; import { isVisibleStage } from "../data/runs"; import { formatDurationSecs } from "../lib/format"; import type { PaginatedRunStageList } from "@qltysh/fabro-api-client"; -import type { RunSettingsLayer } from "../lib/workflow-api"; export const handle = { wide: true }; +type WorkflowSettingsSnapshot = Record; export async function loader({ request, params }: any) { const [{ data: apiStages }, settings] = await Promise.all([ apiJson(`/runs/${params.id}/stages`, { request }), - apiJson(`/runs/${params.id}/settings`, { request }), + apiJson(`/runs/${params.id}/settings`, { request }), ]); const stages: Stage[] = apiStages.filter((s) => isVisibleStage(s.id)).map((s) => ({ id: s.id, diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index 0999ef984..c1697bd41 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -2,7 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { Link, Outlet, useLocation, useParams } from "react-router"; import { apiJsonOrNull } from "../api"; import type { - RunSettingsLayer, + WorkflowSettingsSnapshot, WorkflowDetailResponse as ApiWorkflowDetail, } from "../lib/workflow-api"; @@ -11,14 +11,15 @@ export interface WorkflowEntry { slug: string; description: string; filename: string; - settings: RunSettingsLayer; + settings: WorkflowSettingsSnapshot; graph: string; } // Static sample data used by the `workflow-definition` index route for the -// hardcoded showcase workflows. Shape mirrors the persisted `SettingsLayer` -// JSON returned by `/api/v1/runs/:id/settings`. Fields are opaque to the -// `RunSettingsLayer` TypeScript type, which is a bare `Record`. +// hardcoded showcase workflows. Shape mirrors the persisted +// `WorkflowSettings` snapshot returned by `/api/v1/runs/:id/settings`. +// Fields stay opaque to the `WorkflowSettingsSnapshot` TypeScript alias, +// which is a bare `Record`. export const workflowData: Record = { fix_build: { name: "Fix Build", diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index dd4e8ac27..b6d4c77d0 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -1554,7 +1554,7 @@ paths: operationId: retrieveRunSettings tags: [Run Internals] summary: Retrieve Run Settings - description: Returns the persisted `SettingsLayer` used to launch this run. + description: Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. parameters: - $ref: "#/components/parameters/RunId" responses: @@ -1563,7 +1563,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RunSettingsLayer" + $ref: "#/components/schemas/WorkflowSettings" "404": description: Run not found content: @@ -5820,10 +5820,10 @@ components: type: string enum: [tailscale_funnel, server_url] - RunSettingsLayer: + WorkflowSettings: description: | - The persisted `SettingsLayer` used for a specific run, serialized as-is. - This matches the stored run manifest shape rather than a resolved view. + The persisted dense `WorkflowSettings` snapshot used for a specific run. + This matches the resolved run settings recorded at launch time. type: object additionalProperties: true diff --git a/docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md b/docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md index c50b78300..8338eaaf8 100644 --- a/docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md +++ b/docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md @@ -809,7 +809,7 @@ memory. Delete the redaction machinery. Typed OpenAPI schema via - Replace `ServerSettings`'s `additionalProperties: true` with a typed schema (two fields: `server`, `features`) matching the Rust `ServerSettings`. - - Rename the `RunSettings` schema to `RunSettingsLayer` and update its + - Rename the `RunSettings` schema to a sparse run-settings wire name and update its description to reflect that the endpoint returns the persisted `SettingsLayer` as-is. - Remove all `redact`, `redaction`, `secret subtrees` language across @@ -883,7 +883,7 @@ memory. Delete the redaction machinery. Typed OpenAPI schema via - *Contract:* Generated TypeScript client returns a typed object with `server` and `features` fields. - *Contract:* `GET /api/v1/runs/:id/settings` returns the persisted - `SettingsLayer` (renamed `RunSettingsLayer` in the spec) directly. + `SettingsLayer` (renamed to the sparse run-settings wire name in the spec) directly. - *Behavior:* `server.listen` is present and visible in the main settings response. - *Integration:* OpenAPI conformance passes. @@ -961,7 +961,7 @@ and their constructors are the primary API; internal helpers go - **API surface:** `GET /api/v1/settings` shape changes (single dense `ServerSettings` served from `AppState`). `GET /api/v1/runs/:id/settings` shape unchanged (still the persisted `SettingsLayer`; OpenAPI schema - renamed `RunSettingsLayer`). + renamed to the sparse run-settings wire name). - **Integration coverage:** OpenAPI conformance guards spec/router alignment. Progenitor regen + `bun run generate` + SPA refresh is the known hygiene. diff --git a/docs/plans/2026-04-23-003-refactor-config-types-boundary-and-dense-migration-plan.md b/docs/plans/2026-04-23-003-refactor-config-types-boundary-and-dense-migration-plan.md new file mode 100644 index 000000000..072ef0de0 --- /dev/null +++ b/docs/plans/2026-04-23-003-refactor-config-types-boundary-and-dense-migration-plan.md @@ -0,0 +1,1010 @@ +--- +title: "refactor: fabro-config types boundary and dense-type migration" +type: refactor +status: completed +date: 2026-04-23 +--- + +# Fabro Config: Types Boundary & Dense-Type Migration + +## Overview + +Completion note (2026-04-23): All implementation units in this migration landed. The closing audit removed the retired sparse run-settings schema name from code/docs, verified the sparse-type boundary greps, and left the branch at the intended dense-settings end state. + +Finish the `fabro-config` boundary refactor by doing four things in one coherent sweep: + +1. Replace the post-merge `enforce_server_authority` kludge with the existing `Combine` machinery. Strip `server.*`, `cli.*`, and `features.*` from the materialized run layer at the builder boundary. Server's `run.*` keeps its current semantic as a low-precedence default that client layers override — **no change to user-facing override behavior**. +2. Migrate every consumer of materialized run settings from sparse `SettingsLayer` to dense `WorkflowSettings` / `UserSettings` / `ServerSettings`. This includes retyping `RunSpec.settings` and `RunCreatedProps.settings` in `fabro-types` from sparse `SettingsLayer` to dense `WorkflowSettings`. Snapshot dense `WorkflowSettings` into `Event::RunCreated`. Update the `/api/v1/runs/{id}/settings` endpoint to return dense JSON. +3. Move the dense bundle types (`WorkflowSettings`, `UserSettings`, `ServerSettings`) from `fabro-config` to `fabro-types`. They're resolved vocabulary that consumers read from — construction stays in `fabro-config` as `XxxBuilder` types / free functions. This move is what unblocks (2), because `RunSpec` (in `fabro-types`) needs to reference `WorkflowSettings` without a reverse crate dependency. +4. Move `SettingsLayer`, all `*Layer` sub-layers, the `Combine` trait, and merge-specific collection types (`MergeMap`, `ReplaceMap`, `StickyMap`, `SpliceArray`) from `fabro-types` into `fabro-config`. Make `SettingsLayer` and the merge machinery `pub(crate)`. Keep sub-layer types `pub` (the CLI needs them for programmatic overrides from clap args). Rewrite the `fabro-macros::Combine` derive to use an absolute trait path so it still resolves after the move. Keep resolved `*Namespace` types and value types (`Duration`, `InterpString`, `ModelRef`, `Size`) in `fabro-types`. + +Along the way, delete the one-liner free functions and thin resolver wrappers that have accumulated: `parse_settings_layer`, `apply_builtin_defaults`, `defaults_layer`, `render_resolve_errors`, `resolve_storage_root`, `EffectiveSettingsLayers`, `materialize_settings_layer` (free fn), `strip_owner_domains`, and the whole `resolve_*_from_file` stack. + +**Why as one plan rather than split:** partial landings leave the codebase worse — half-migrated types, dead wrappers kept as "compat shims," and mechanism leaking back across the boundary. The anti-regression checklist at the end exists specifically to make the landed state observable and drift-resistant. Every item on that checklist must be true when the plan closes. + +## Problem Frame + +`fabro-config` has accumulated complexity across several dimensions. Prior plans tackled discrete slices (`Resolver` indirection, `Combine`-derive pattern, `CommandContext` alignment, owner-first context types). After those landed, these issues remain: + +1. **`enforce_server_authority` is a post-pass kludge** in `effective_settings.rs`. It merges, then "fixes up" the merge for a curated subset of server-owned fields (`storage`, `scheduler`, `artifacts`, `web`, `api`) and overwrites `features` wholesale. The logic is split across three places (`strip_owner_domains`, the combine, `enforce_server_authority`) and maintains an implicit authoritative-subset list. An existing bug falls out of this pattern: if the server's `server.storage.root` is unset and a user's is set, the user's value leaks into runs rather than falling through to defaults. + +2. **`SettingsLayer` leaks far past config resolution.** It appears as a field on `PreparedManifest`, `CommandContext.machine_settings`, `Event::RunCreated.settings` (via `serde_json::Value`), and in signatures of `fabro-server::replace_settings`, `web_auth`, and the entire `fabro-cli/src/local_server.rs` module. Consumers repeatedly do `.as_ref().and_then(...)` chains against the sparse shape. The `local_server.rs` docstring even admits: *"This module is the only generic CLI lifecycle surface allowed to read `[server.*]` settings"* — a carve-out that exists only because the type boundary failed. + +3. **`fabro-types` conflates universal vocabulary with config-resolution mechanism.** `fabro-types` should be the language every crate speaks. The sparse `*Layer` types and `Combine` trait are specific to the act of config resolution — they belong in `fabro-config`, not in the shared vocabulary crate. See memory `project_fabro_types_vs_config`. + +4. **`EffectiveSettingsLayers` is a random-looking parameter-bag type** with no domain content — a 4-field tuple with `new()` and `Default`. + +5. **One-liner free functions** have accumulated: `parse_settings_layer` wrapping `toml::from_str`, `defaults_layer` returning a `&'static`, `apply_builtin_defaults` calling one `.combine()`, `render_resolve_errors` joining strings, the `resolve_*_from_file` stack each doing ~6 lines of pass-through. Each reader pays a jump cost to learn the wrapper does nothing. See memory `feedback_avoid_oneliner_free_functions`. + +6. **Owner-specific domain stripping logic is scattered.** "`cli`/`server` only come from user/args," "`features` is server-authoritative," "runs never see server.*" — these rules live across multiple functions and docstring comments. Should be centralized at one boundary: the builder. + +7. **`Event::RunCreated.settings` persists the sparse layer shape.** Replay reads a sparse merge intermediate rather than "what the run actually saw." A dense snapshot is strictly more reliable: defaults can't drift after the fact. + +Greenfield app, no production deployments, single-node per memory `project_fabro_is_single_node` — product behavior changes are acceptable where they simplify the model. + +## Requirements Trace + +- **R1.** Server operator retains the ability to **supply defaults** for `run.*` fields (notably `run.sandbox.provider`). Client layers (args, workflow, project, user) continue to override these defaults exactly as they do today. No user-facing override behavior changes; the refactor only changes *how* the default flows into the merge (via `Combine`, not via a post-pass overlay). +- **R2.** Runs never see `server.*`, `cli.*`, or `features.*` in their materialized settings. Runtime code that needs those reads from the live `ServerSettings` / `UserSettings` directly. +- **R3.** `SettingsLayer` is `pub(crate)` inside `fabro-config` after this plan lands. No crate outside `fabro-config` names the type — **including integration tests under `lib/crates/fabro-config/tests/`**, which compile as external crates and therefore cannot access `pub(crate)` items. Existing integration tests that name `SettingsLayer` migrate into `#[cfg(test)] mod tests` blocks inside `src/**/*.rs` (or, if cross-file sharing matters, behind a `test-support` feature flag). +- **R4.** Sub-`*Layer` types (`RunLayer`, `CliLayer`, `ProjectLayer`, `WorkflowLayer`, `ServerLayer`, `FeaturesLayer`, plus their sub-sub-layers) remain `pub` in `fabro-config` to let CLI-arg adapters construct programmatic overrides. +- **R5.** Every consumer of materialized run settings takes or holds a dense type. No struct outside `fabro-config` has a `settings: SettingsLayer` field. Specifically, `RunSpec.settings` and `RunCreatedProps.settings` in `fabro-types` retype from `SettingsLayer` to `WorkflowSettings`. +- **R6.** `Event::RunCreated.settings` serializes a dense `WorkflowSettings` snapshot (naturally, once `RunCreatedProps.settings: WorkflowSettings`). Replay consumers see "what the run saw," not a sparse merge intermediate. Wire JSON shape changes — the event schema is internal, so this is acceptable. +- **R7.** Crate split: value types (`Duration`, `InterpString`, `ModelRef`, `Size`), resolved `*Namespace` types, **and the dense bundle types (`UserSettings`, `ServerSettings`, `WorkflowSettings`)** stay in (or move to) `fabro-types`. `*Layer` types, `SettingsLayer`, `Combine`, `MergeMap`/`ReplaceMap`/`StickyMap`/`SpliceArray` move to `fabro-config`. `fabro-types` must not depend on `fabro-config` — preserved as an absolute dependency-direction constraint. Rationale: memory `project_fabro_types_vs_config` — dense types are vocabulary that consumers reason about; construction is an operation. +- **R8.** One-liner wrappers listed in the anti-regression checklist are deleted — not kept as pass-through shims. Replaced either by inlining, `FromStr`/`From` impls, or methods on the relevant type. +- **R9.** Test scenarios exercising the existing `server.storage.root` user-leak bug (user sets `server.storage.root`, server does not) document the new "server-owned always means server-owned" behavior. Runs no longer see `server.*` at all, so the user's `server.storage.root` cannot leak into run storage. +- **R10.** The `Combine` derive macro in `fabro-macros` is rewritten to use an absolute trait path (`::fabro_config::layers::Combine`) so it resolves correctly after the trait moves out of `fabro-types`. The derive's output references a `pub(crate)` trait, so it is only usable *inside* fabro-config. That constraint is acceptable because after Unit 3.1 every `*Layer` struct lives in fabro-config and every `#[derive(Combine)]` site is inside fabro-config. The macro's rustdoc explicitly documents this scoping so a future engineer adding a layer type outside fabro-config understands why it won't compile. +- **R11.** The `/api/v1/runs/{id}/settings` API endpoint's response shape changes from the old sparse run-settings schema to dense `WorkflowSettings`. OpenAPI schema renamed accordingly. The TypeScript client and web UI (`apps/fabro-web/app/routes/run-settings.tsx`) update in lockstep. Internal wire contract; change is accepted. + +## Scope Boundaries + +**In scope:** +- Changes listed in Requirements. +- Deletion of the named wrappers and parameter-bag types. +- Splitting settings files in `fabro-types` so Namespaces stay and Layers move. +- **Moving dense bundle types (`WorkflowSettings`, `UserSettings`, `ServerSettings`) from `fabro-config::context` to `fabro-types`.** +- **Retyping `fabro-types::run::RunSpec.settings` and `fabro-types::run_event::run::RunCreatedProps.settings` from `SettingsLayer` to `WorkflowSettings`.** +- **Rewriting the `Combine` derive macro in `fabro-macros` to use an absolute trait path.** +- **Updating `/api/v1/runs/{id}/settings` endpoint + OpenAPI schema + TypeScript client + web UI renderer to the dense shape.** +- Updating `use` statements workspace-wide to point at the new homes. +- Updating `Cargo.toml` dependencies where callers now need `fabro-config` for a type they previously got from `fabro-types` (or vice-versa for the dense bundles). + +**Out of scope:** +- Redesigning the TOML schema. All user-facing TOML shapes are unchanged. +- Changing `UserSettings` / `ServerSettings` / `WorkflowSettings` field layout beyond what's needed to relocate them (the shapes are structural moves, not redesigns). +- Touching `InterpString`, `Duration`, `Size`, `ModelRef` — they're correctly placed. +- Introducing new settings domains or feature flags. +- Altering the resolver error types beyond wrapping `Vec` in a `ResolveErrors` newtype for `Display`. +- Multi-node / shared-server policy semantics (single-node, see memory). +- **Policy enforcement features — this plan keeps the existing "server supplies defaults, client overrides" semantic.** If hard enforcement becomes a requirement later, it'll be a distinct plan. +- Modifications to TOML parsing internals other than switching the parse entrypoint to `FromStr`. +- Renames of the resolved `*Namespace` types. + +## Context & Research + +### Prior plans (land this on top of) + +- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` — introduces owner-first `ServerSettings` / `UserSettings` context types. Status: `active` at time of writing this plan. **This plan assumes those context types are in place.** If 04-22-001 has not yet landed, it must land first. +- `docs/plans/2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md` — deletes `Resolver`, `ResolvedSettingsTree`, adds `WorkflowSettings`. Status: `completed`. **This plan assumes `WorkflowSettings` exists as a `{ project, workflow, run }` bundle.** +- `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md` — replaces `merge.rs` with `Combine` trait + derive macro. Status: `completed`. **This plan leverages the `Combine` trait directly for all layer merging. The derive macro itself needs a small rewrite (R10) to use an absolute path after the trait moves out of `fabro-types`.** +- `docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md` — finishes `CommandContext` abstraction. Status: `completed`. **This plan retypes `CommandContext.machine_settings` from `SettingsLayer` to the appropriate dense type.** + +### Relevant code and patterns + +- `lib/crates/fabro-config/src/effective_settings.rs` — home of `EffectiveSettingsLayers`, `materialize_settings_layer`, `enforce_server_authority`, `strip_owner_domains`. All four are deleted by this plan. +- `lib/crates/fabro-config/src/context.rs` — `UserSettings`, `ServerSettings`, `WorkflowSettings` live here today with `from_layer` associated functions. **These three type definitions move to `fabro-types`** (per R7); their construction moves to new `UserSettingsBuilder` / `ServerSettingsBuilder` / `WorkflowSettingsBuilder` types in `fabro-config`. Inherent `impl UserSettings { fn from_layer(...) }` blocks cannot live in fabro-config once the type is in fabro-types (Rust coherence). Because `SettingsLayer` is `pub(crate)`, every `*Builder` method that takes a `SettingsLayer` is `pub(crate)` (usable only by fabro-config itself for internal construction and tests). External callers use the public file/TOML-string/sub-Layer entry points documented in the High-Level Technical Design: `UserSettingsBuilder::load_from(&path)?`, `UserSettingsBuilder::from_toml(source)?`, `ServerSettingsBuilder::load_from(&path)?`, `WorkflowSettingsBuilder::new().user_file(...).build()?`, etc. +- `lib/crates/fabro-types/src/run.rs:54` — `RunSpec.settings: SettingsLayer` retypes to `WorkflowSettings`. +- `lib/crates/fabro-types/src/run_event/run.rs:12` — `RunCreatedProps.settings: SettingsLayer` retypes to `WorkflowSettings`. Knock-on: `fabro-workflow/src/event.rs:1513` (the `event_body_from_event` deserializer) now targets `WorkflowSettings` naturally — no dedicated handling needed, because the emitter side and the deserializer side agree on the dense shape after this plan. +- `lib/crates/fabro-macros/src/lib.rs:177-182` — `Combine` derive expansion uses `crate::settings::Combine` (relative). Rewrite to use the absolute path `::fabro_config::layers::Combine`. Because `Combine` is `pub(crate)` after Unit 3.1, the derive is only usable for types defined inside fabro-config — which is every `*Layer` struct after the move, so this suffices. Document the in-crate-only scoping in the macro's rustdoc. +- `docs/api-reference/fabro-api.yaml` — `/api/v1/runs/{id}/settings` endpoint (line ~1374) and its old sparse run-settings schema (line ~5448). Schema renamed to something like `RunSettings` matching the dense shape; response type changes. +- `lib/packages/fabro-api-client/` — regenerated TypeScript Axios client picks up the new schema name and shape. +- `apps/fabro-web/app/routes/run-settings.tsx` — adjust to consume the new dense shape. +- `lib/crates/fabro-config/src/resolve/mod.rs` — home of `resolve_*_from_file` stack and `render_resolve_errors`. All of this is deleted or inlined; per-namespace resolution becomes methods on `XxxSettingsBuilder` types or a single internal helper. Known caller count: **~80–108 across 9 crates** (per P1-5 from the review); this caller migration is split across Units 1.2 and 2.x rather than one unit. +- `lib/crates/fabro-config/src/defaults.rs` — `defaults_layer` + `apply_builtin_defaults` one-liners. Inlined into builder `build()`. +- `lib/crates/fabro-config/src/parse.rs` — `parse_settings_layer` wraps `toml::from_str`. Replaced by `impl FromStr for SettingsLayer` and (optionally) a `pub(crate) fn SettingsLayer::parse(s)` convenience. +- `lib/crates/fabro-config/src/load.rs` — file-loading entry points. Each becomes either a builder method or a `pub(crate) SettingsLayer::load_from` associated function. +- `lib/crates/fabro-types/src/settings/` — current home of every Layer and Namespace. Each domain file splits; Layers move, Namespaces + value enums stay. +- `lib/crates/fabro-server/src/run_manifest.rs` — `PreparedManifest` bundle. `settings: SettingsLayer` field becomes `settings: WorkflowSettings`. Preflight code reading `resolve_run_from_file(&prepared.settings)` accesses `prepared.settings.run` directly. Existing test at `run_manifest.rs:1047` asserting `resolved_server.integrations.github.app_id` snapshotted into `prepared.settings` is obsolete — deleted (verified: production code reads integrations from `state.server_settings()`, not from `prepared.settings`). +- `lib/crates/fabro-workflow/src/event.rs` — `Event::RunCreated.settings: serde_json::Value` — the shape source switches from sparse `SettingsLayer` to dense `WorkflowSettings`. Emitters in `dump.rs`, `test_support.rs`, `runtime_store.rs` update. +- `lib/crates/fabro-cli/src/local_server.rs` — entire module takes `&SettingsLayer` today and `.as_ref().and_then(...)` chains values out. Switches to `&ServerSettings`. The "only surface allowed to read server.*" docstring is removed. +- `lib/crates/fabro-cli/src/command_context.rs` — `machine_settings: SettingsLayer` retyped. The field's two consumers (server connection, explicit settings accessor) both want server-scoped data — either split into two typed fields or collapse to the single `ServerSettings` needed. +- `lib/crates/fabro-cli/src/user_config.rs` — `load_settings() -> SettingsLayer` today. Returns dense types after this plan. +- `lib/crates/fabro-server/src/server.rs:785` `replace_settings(SettingsLayer)` — hot-swap API retyped to `replace_server_settings(ServerSettings)`. +- `lib/crates/fabro-server/src/web_auth.rs:907` — takes `SettingsLayer`; retyped to take `&ServerSettings`. +- `lib/crates/fabro-server/src/serve.rs` — `resolve_bind_request_from_settings(&SettingsLayer, ...)` retyped to take `&ServerSettings`. + +### Institutional learnings + +- Memory `feedback_avoid_oneliner_free_functions` — one-liner wrappers should be deleted, not kept as thin shims. Applies throughout this plan. +- Memory `feedback_oop_style_rust` — prefer methods on types over free functions. The builder choice and the `from_layer` method choice both follow this. +- Memory `feedback_prefer_robust_types_over_efficiency` — the whole plan is applying this principle (stronger typed boundaries, no allocation-based shortcuts to skip typed roundtrips). +- Memory `project_fabro_types_vs_config` — codifies the crate split this plan executes. `fabro-types = vocabulary`, `fabro-config = operation`. +- Memory `project_fabro_is_single_node` — constrains scope: no multi-node enforcement semantics. + +### External references + +None gathered. The refactor operates entirely within repo patterns and the `uv`-pattern `Combine` trait already cited in `docs/plans/2026-04-23-002-...` (which is already landed). + +## Key Technical Decisions + +- **Replace `enforce_server_authority` with `Combine`-based merge; don't change precedence.** Server's `run.*` continues to flow at its existing lowest-precedence default position. The post-pass overlay of `server.{storage,scheduler,artifacts,web,api}` and `features` disappears because runs no longer see any of those — consumers read them from `ServerSettings` directly. **Rationale:** eliminates the authoritative-subset list, `enforce_server_authority`, and `strip_owner_domains`. Client-override semantics (user can still set their own `run.sandbox.provider` to override a server-supplied default) are preserved exactly as today. Same user-visible behavior; simpler mechanism. + +- **Precedence order for `run.*` (unchanged vs. today): `args > workflow > project > user > server.run > built-in defaults`.** Higher position wins. Server's `run.*` stays at the lowest-client-layer position, above only built-in defaults. The refactor does not flip this — my earlier draft misread the product intent, corrected after review. + +- **Runs don't see `server.*`, `cli.*`, or `features.*` at all.** Ordering matters: the builder first **merges all layers including built-in defaults** (which today carry `[cli.*]`, `[server.*]`, and `[features]` entries), and **only then** zeroes `server`/`cli`/`features` to `None` on the returned sparse layer. Stripping before applying defaults would let the defaults reintroduce the fields; stripping after is correct. In practice the final dense `WorkflowSettings` structurally lacks those fields anyway, so even a mis-ordered strip would not show up in the resolved output — but the intermediate sparse layer must be clean for any caller that inspects it. **Rationale:** eliminates the flow-through and the authoritative-subset list; centralizes "what runs see" in one place; forces runtime code needing those values to read from the live `ServerSettings` / `UserSettings`, which is where they already should be coming from. + +- **Dense bundle types (`WorkflowSettings`, `UserSettings`, `ServerSettings`) live in `fabro-types`; their construction lives in `fabro-config`.** The type definitions are vocabulary — consumers across the workspace read from them. Construction (from raw TOML, from file paths, from clap-arg-derived sub-Layers) is an operation that only fabro-config needs to know. Rust's coherence rule means inherent impls must live where the type is defined, so `UserSettings::from_layer` / `ServerSettings::from_layer` / `WorkflowSettings::builder()` as methods are not available — instead, construction uses sibling builder types in fabro-config. **`SettingsLayer` is `pub(crate)`, so any `*Builder` method that takes a `SettingsLayer` is `pub(crate)`** (serving fabro-config's own construction paths and tests). The public API of each `*Builder` takes only public input types — paths, TOML strings, sub-Layer types. **Rationale:** breaks the circular dependency that would otherwise prevent `RunSpec.settings: WorkflowSettings` (RunSpec lives in fabro-types and can't depend on fabro-config); keeps the `SettingsLayer` aggregator truly private (no `private_bounds` warnings under `clippy -D warnings`). + +- **`Event::RunCreated.settings` serializes dense `WorkflowSettings` naturally, once `RunCreatedProps.settings: WorkflowSettings`.** No special emitter handling needed — the field type change propagates through the serde roundtrip. **Rationale:** replay gets "what the run saw" rather than a sparse merge intermediate vulnerable to later defaults drift. The event schema is internal to Fabro. + +- **Introduce `WorkflowSettingsBuilder` in `fabro-config`.** Five internal slots (args, workflow, project, user, server). Because `SettingsLayer` is `pub(crate)`, the public setters accept only public input types: file paths, TOML strings, `&ServerSettings` (dense), and sub-Layer types (`RunLayer`, `CliLayer`) for clap-arg overrides. Internal `pub(crate)` setters accepting raw `SettingsLayer` exist for fabro-config's own tests. `build()` merges all slots with built-in defaults, then zeroes `server`/`cli`/`features` to `None` on the intermediate sparse layer, then resolves to dense `WorkflowSettings` (which lives in fabro-types). Call site: `WorkflowSettingsBuilder::new().user_file(&path)?.project_file(&p)?.run_overrides(run).build()?`. **Rationale:** replaces `materialize_settings_layer` free fn and the `EffectiveSettingsLayers` struct; names each layer at the call site; makes optional slots natural. Per-slot contribution rules live in the builder's docstring — the single place the stripping/merging policy is documented. The public API never exposes `SettingsLayer`, avoiding `private_bounds` warnings under `clippy -D warnings`. + +- **`UserSettingsBuilder` / `ServerSettingsBuilder` replace the previous `UserSettings::from_layer` / `ServerSettings::from_layer` associated functions.** Each exposes a small set of associated constructors that return the dense bundle directly (not a builder chain — there's only one input, so there's nothing to chain): + - `pub fn load_from(path: &Path) -> Result` — loads the TOML file and resolves. + - `pub fn from_toml(source: &str) -> Result` — parses a TOML string and resolves. + - `pub(crate) fn from_layer(layer: &SettingsLayer) -> Result` — internal path for fabro-config's own consumers (e.g., the `WorkflowSettingsBuilder` machinery and internal tests). **`pub(crate)` because `SettingsLayer` is `pub(crate)`** — this signature cannot be public without a `private_bounds` warning. + - `UserSettingsBuilder::load_default() -> Result` loads from `~/.fabro/settings.toml` (the conventional path). +- **Storage-override replacement for `apply_storage_dir_override` is a post-resolve method on the dense type, not a builder step.** Define `ServerSettings::with_storage_override(self, path: &Path) -> ServerSettings` (inherent impl on `ServerSettings`, which lives in fabro-types). Callers do `ServerSettingsBuilder::load_from(&path)?.with_storage_override(&dir)`. This composes cleanly because `with_storage_override` takes `ServerSettings` and returns `ServerSettings` — exactly what `load_from` produces. Keeps `ServerSettingsBuilder` to just its three constructors (simpler API; no phantom lifecycle). **Rationale:** Rust coherence forces constructors to live where the type is defined; since the types now live in fabro-types and construction lives in fabro-config, sibling builder types are the mechanism. Post-resolve overrides on the dense type keep the builder API honest — builders build; they don't mutate. + +- **Move all `*Layer` types and merge mechanism from `fabro-types` to `fabro-config`.** `SettingsLayer`, `Combine`, `MergeMap`, `ReplaceMap`, `StickyMap`, `SpliceArray` go with them. `SettingsLayer`, `Combine`, and `SpliceArray` become `pub(crate)`. `MergeMap` / `ReplaceMap` / `StickyMap` are `pub` (CLI adapters construct `ReplaceMap` values). Sub-Layer types (`RunLayer`, `CliLayer`, etc.) remain `pub` — the CLI needs them for constructing programmatic overrides from clap args. Encoded in memory `project_fabro_types_vs_config`. + +- **Type-move is one atomic PR.** `fabro-types` must not depend on `fabro-config`; re-export bridges to ease transition would create a circular dep. Big diff, mechanical, unavoidable. + +- **Rewrite the `Combine` derive macro in `fabro-macros` to use an absolute trait path.** Today it expands to `crate::settings::Combine::combine(...)` and `impl ... crate::settings::Combine for ...`. That only works because the trait lives at `fabro_types::settings::Combine` and all deriving types are in fabro-types. Once the trait moves into `fabro_config::layers::combine::Combine`, the relative path breaks. Rewrite the macro expansion to use `::fabro_config::layers::Combine` (absolute). + + **Visibility constraint:** `Combine` remains `pub(crate)` inside fabro-config. Proc-macro output is substituted at the call site and compiled as if the caller wrote it, which means downstream crates cannot produce `#[derive(Combine)]` expansions that reference a `pub(crate)` trait — they would fail to compile with "use of private trait." **This is acceptable because after Unit 3.1 every `#[derive(fabro_macros::Combine)]` call site is inside fabro-config** (every `*Layer` struct moved there). The derive is effectively scoped to fabro-config-internal use. Document this in the macro's own rustdoc so a future engineer adding a new layer type outside fabro-config gets a clear error. **Rationale:** unblocks Unit 3.1 for all in-scope deriving types; keeps the merge-trait surface tight. + +- **`/api/v1/runs/{id}/settings` endpoint switches to dense `WorkflowSettings`.** OpenAPI schema renamed from the old sparse run-settings name to `RunSettings`, TypeScript Axios client regenerates, web UI renderer at `apps/fabro-web/app/routes/run-settings.tsx` updates in the same PR. **Rationale:** internal endpoint with one known consumer (the fabro web UI); greenfield means we can just change the shape. Replay-shape and API-shape stay aligned with storage shape (dense throughout). + +- **`FromStr` for `SettingsLayer` and `From for SettingsLayer` for the six sub-layer types.** **Rationale:** `FromStr` replaces `parse_settings_layer` with a universally-recognized idiom (usable inside fabro-config); `From`/`From`/etc. collapse test fixture boilerplate (`SettingsLayer { run: Some(RunLayer { ... }), ..Default::default() }` becomes `RunLayer { ... }.into()`). After Unit 3.1, `SettingsLayer` is `pub(crate)` — **these trait impls are `pub(crate)`** and benefit only fabro-config's own unit tests (plus the internal `pub(crate)` builder setters). External callers access the builder through public file-path / TOML-string / sub-Layer setters instead. + +- **`render_resolve_errors` becomes `impl Display for ResolveErrors` newtype.** `ResolveErrors(Vec)` with a `;`-joined `Display`. Preserves the existing error-presentation format (R6 of 04-23-001-collapse called this out explicitly). Replaces the free fn without losing the formatting contract. + +- **Delete the existing `resolve_*_from_file` free-fn stack.** Per 04-23-001-collapse these had "dozens of callers" — workspace grep finds ~80–108 across 9 crates. The caller migration is chunked across Units 1.2 and 2.x rather than one unit. Each caller is retyped to use the relevant dense bundle (`ServerSettings`, `UserSettings`, `WorkflowSettings`) or a `pub(crate)` helper within fabro-config. No top-level public free fn named `resolve_*_from_file` remains at end of plan. **Rationale:** memory `feedback_avoid_oneliner_free_functions`. + +## Open Questions + +### Resolved during planning + +- **Should `cli.*` still flow through the materialized run layer for `--verbose` display?** No. `CliOutputLayer::verbosity` from args flows into `UserSettings` via the CLI-side loader, not into run settings. Any run-side code that today reads `cli.output.verbosity` from the materialized layer migrates to reading `UserSettings.cli` instead. Confirmed no production consumer today. +- **What about `features` flowing into runs?** Runs don't see features. Runtime code needing feature flags reads `ServerSettings::features` directly. Confirmed during earlier investigation: `WorkflowSettings` today already doesn't expose `features` to runs. +- **Backwards compatibility of `Event::RunCreated` JSON?** Schema is internal. Greenfield, no production consumers. Shape change is accepted. +- **Preserving existing `server.storage.root` user-leak behavior?** No — this is the bug referenced in R9. Runs no longer see `server.*` at all, so `user.server.storage.root` cannot leak into run storage regardless of what the server sets. +- **Can sub-Layer types be `pub(crate)` too?** No — CLI adapters in `fabro-cli/src/commands/run/overrides.rs` and `fabro-cli/src/args.rs` construct them from clap args. They must stay `pub`. Only the top-level aggregator (`SettingsLayer`), the `Combine` trait, and `SpliceArray` go `pub(crate)`. `MergeMap` / `ReplaceMap` / `StickyMap` stay `pub` because CLI-adapter code constructs `ReplaceMap` values. +- **Should `WorkflowSettingsBuilder` live on `WorkflowSettings` (as `WorkflowSettings::builder()`) or as a separate type?** As a separate type in `fabro-config`. Rust coherence forces this: `WorkflowSettings` lives in `fabro-types` after Decision 1, and `fabro-types` cannot depend on `fabro-config` to supply the builder. Call sites use `WorkflowSettingsBuilder::new().user(u)...build()?`. +- **Does the server's `run.*` precedence change?** No. It stays at its current lowest-client-layer position. The refactor replaces `enforce_server_authority` with straight `Combine` but does not flip precedence. Client override of server-supplied `run.*` defaults is preserved. +- **Where do the dense bundle types live?** In `fabro-types`, moved from `fabro-config::context` as part of this plan. Rationale: they're vocabulary that consumers reason about; construction is an operation. See memory `project_fabro_types_vs_config`. +- **How does `#[derive(fabro_macros::Combine)]` resolve the trait after the move?** The derive macro emits an absolute path `::fabro_config::layers::Combine`. Because `Combine` is `pub(crate)`, this absolute path only resolves from *inside* fabro-config. After Unit 3.1 every `*Layer` struct (and therefore every `#[derive(Combine)]` site) is inside fabro-config, so this works. If a future crate outside fabro-config wants to derive `Combine`, either `Combine` would need to be made `pub`, or that new layer type would need to live in fabro-config. This scoping is documented in the macro's rustdoc. +- **Does the `/api/v1/runs/{id}/settings` endpoint change shape?** Yes — it now returns dense `WorkflowSettings` JSON. OpenAPI schema renamed from the old sparse run-settings name to `RunSettings`. Web UI and TypeScript client update in lockstep. +- **`MergeMap`/`ReplaceMap`/`StickyMap` visibility after the move.** These appear only inside `*Layer` types — resolved `Namespace` types use plain `HashMap` (e.g., `ProjectNamespace.metadata`) or concrete resolved wrappers (e.g., `RunNamespace.notifications: HashMap`), never these merge-specific newtypes. They move to `fabro-config/src/layers/maps.rs` alongside the other Layer machinery. Visibility is **`pub`** (not `pub(crate)`) because `fabro-cli/src/commands/run/overrides.rs` and `fabro-server/src/run_manifest.rs` construct `ReplaceMap` values at CLI-arg translation sites. + +### Deferred to implementation + +- **Home of `Storage`, `RuntimeDirectory`, `RunScratch` currently in `fabro-config/src/storage.rs`.** These may be runtime filesystem helpers rather than config. Skim and decide during the final audit unit. Not blocking. +- **Exact split of `CommandContext.machine_settings`.** Today one `SettingsLayer` field supports both server-connection needs (bind/storage) and "explicit read access for callers who ask." Implementer decides whether two dense fields or one `ServerSettings` plus the already-present `UserSettings` is cleaner. +- **Specific method/helper names during inlining.** Where this plan names a behavior (e.g., `SettingsLayer::load_from`), the implementer can choose a better name if one fits the actual use; naming decisions that require real-code-in-hand don't belong in the plan. +- **Whether `resolve_storage_root` becomes a method on `ServerSettings` or gets inlined at its one or two call sites.** Implementer decides. Either is acceptable as long as no top-level `pub fn resolve_storage_root` free function remains. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +### Builder shape + +```rust +// Pseudo-code — the shape of the new entry point. +// Lives in fabro-config; WorkflowSettings itself lives in fabro-types. +// SettingsLayer is pub(crate) inside fabro-config, so it does NOT appear +// in public builder signatures (would trip `private_bounds` under +// clippy -D warnings). Public setters accept public input types only. + +pub struct WorkflowSettingsBuilder { + args: SettingsLayer, // pub(crate) field type — internal only + workflow: SettingsLayer, + project: SettingsLayer, + user: SettingsLayer, + server: SettingsLayer, +} + +impl WorkflowSettingsBuilder { + pub fn new() -> Self { Self::default() } + + // Per-slot contribution rules documented in the type's docstring — + // the single place that policy lives: + // args/workflow/project/user — all run/project/workflow fields + // workflow, project — cli/server/features stripped at build + // user — server/features stripped at build + // server — only `run.*` is used (as lowest-precedence default) + + // Public setters accepting file paths / TOML strings / sub-Layer types. + // All input types here are `pub` (sub-Layer types stay `pub` per R4; + // paths and strings are std). None exposes `SettingsLayer`. + pub fn user_file(self, path: &Path) -> Result; + pub fn user_toml(self, source: &str) -> Result; + pub fn project_file(self, path: &Path) -> Result; + pub fn project_toml(self, source: &str) -> Result; + pub fn workflow_file(self, path: &Path) -> Result; + pub fn workflow_toml(self, source: &str) -> Result; + pub fn server_settings(self, s: &ServerSettings) -> Self; // dense, for the server contributing defaults + + // Programmatic overrides from clap args — the CLI adapters already + // produce RunLayer / CliLayer; sub-Layer types are `pub`. + pub fn run_overrides(self, run: RunLayer) -> Self; + pub fn cli_overrides(self, cli: CliLayer) -> Self; + + // Internal ergonomic sugar for fabro-config's own tests only. + // `pub(crate)` so the `SettingsLayer` bound does not escape. + pub(crate) fn args_layer(self, layer: SettingsLayer) -> Self; + pub(crate) fn user_layer(self, layer: SettingsLayer) -> Self; + pub(crate) fn project_layer(self, layer: SettingsLayer) -> Self; + pub(crate) fn workflow_layer(self, layer: SettingsLayer) -> Self; + pub(crate) fn server_layer(self, layer: SettingsLayer) -> Self; + + pub fn build(self) -> Result; +} +``` + +### Precedence chain for `run.*` + +Higher position = wins. **Unchanged vs. today.** + +``` +args.run (CLI --sandbox etc.) + combine (field-wise merge; unset falls through) +workflow.run (workflow.toml) + combine +project.run (.fabro/project.toml) + combine +user.run (~/.fabro/settings.toml, CLI-side) + combine +server.run (lowest-precedence default from server's settings.toml) + combine +defaults.run (embedded defaults.toml) +``` + +For `project.*` and `workflow.*`: same chain minus `server` (which contributes only `run.*`). +For everything else (`server.*`, `cli.*`, `features.*`): stripped to `None` by `build()`. Runs never see them. + +### Crate-boundary state transitions + +```mermaid +flowchart TB + A[TOML on disk] --> B[SettingsLayer - pub crate in fabro-config] + C[clap args] --> D[RunLayer / CliLayer - pub sub-layers in fabro-config] + D --> B + B --> E[WorkflowSettingsBuilder - fabro-config] + E --> F[WorkflowSettings dense - fabro-types] + F --> G[RunSpec.settings / PreparedManifest.settings] + F --> H[Event RunCreated dense snapshot] + F --> I[Workflow runtime consumers] + F --> X["/api/v1/runs/{id}/settings response"] + J[server ~/.fabro/settings.toml] --> K[ServerSettings dense - fabro-types] + K --> L[AppState server_settings] + L --> M[web_auth, replace_settings, integrations, storage] + N[user ~/.fabro/settings.toml] --> O[UserSettings dense - fabro-types] + O --> P[CommandContext, CLI output, auth target] +``` + +The diagram encodes two key invariants: (1) `SettingsLayer` exists only inside `fabro-config`; every arrow crossing a crate boundary carries a dense type or a `pub` sub-layer. (2) Dense bundles (`WorkflowSettings`, `UserSettings`, `ServerSettings`) live in `fabro-types` and are reachable from both fabro-types consumers (`RunSpec`) and fabro-config builders. + +### File-move map (Phase 3 atomic) + +Each settings domain file in `fabro-types/src/settings/` splits: + +| File | Stays in fabro-types | Moves to fabro-config/src/layers/ | +|------|----------------------|-----------------------------------| +| `cli.rs` | `CliNamespace`, resolved sub-settings, value enums (`OutputFormat`, `OutputVerbosity`, `CliAuthStrategy`) | `CliLayer` + all `Cli*Layer` | +| `server.rs` | `ServerNamespace`, all `Server*Settings` (resolved), all strategy/method value enums | `ServerLayer` + all `Server*Layer` + `ObjectStore*Layer` | +| `project.rs` | `ProjectNamespace` | `ProjectLayer` | +| `workflow.rs` | `WorkflowNamespace` | `WorkflowLayer` | +| `run.rs` | `RunNamespace`, resolved sub-settings, value enums (`ApprovalMode`, `RunMode`, `HookType`, `McpTransport`, `TlsMode`, `MergeStrategy`, `WorktreeMode`, resolved `DaytonaSnapshotSettings`, etc.) | `RunLayer` + all `Run*Layer`, `HookEntry`, `HookAgentMarker`, `HookTlsMode`, `DaytonaNetworkLayer`, `DaytonaDockerfileLayer`, `DaytonaSnapshotLayer`, `NotificationProviderLayer`, `InterviewProviderLayer`, `LocalSandboxLayer`, `ScmGitHubLayer`, `RunGoalLayer`, `RunArtifactsLayer`, `RunCheckpointLayer`, `RunPrepareLayer`, `ModelRefOrSplice`, `StringOrSplice` | +| `features.rs` | `FeaturesNamespace` | `FeaturesLayer` | +| `layer.rs` | — (delete from fabro-types) | `SettingsLayer` (pub(crate)) | +| `combine.rs` | — (delete from fabro-types) | `Combine` trait + all impls (pub(crate)) | +| `maps.rs` | — (delete from fabro-types) | `MergeMap`, `ReplaceMap`, `StickyMap` (pub — CLI adapters construct `ReplaceMap` values) | +| `splice_array.rs` | — (delete from fabro-types) | `SpliceArray`, `SpliceArrayError`, `SPLICE_MARKER` (pub(crate)) | +| `duration.rs`, `interp.rs`, `model_ref.rs`, `size.rs` | stay entirely | — | + +Heuristic for ambiguous types: if it has `Option` fields and a `Combine` impl, it's a Layer → moves. Concrete resolved values with no `Combine` → vocabulary → stays. + +## Implementation Units + +Units are dependency-ordered. Each should land as one atomic commit. Unit 2.0 and Unit 3.1 are the two cross-crate moves and must each land atomically (no bridge re-exports — fabro-types cannot depend on fabro-config). Unit 2.0 is required *before* Unit 2.1 because Unit 2.1 retypes fields in `fabro-types` to reference `WorkflowSettings`, which requires the dense bundle to already live in `fabro-types`. + +```mermaid +flowchart TB + U1_1[Unit 1.1: Builder replaces materialize + post-pass] + U1_2[Unit 1.2: Collapse resolve indirection] + U1_3[Unit 1.3: Delete thin wrappers + FromStr/From impls] + U2_0[Unit 2.0: Move dense bundles into fabro-types] + U2_1[Unit 2.1: Builder returns dense; PreparedManifest + RunSpec retype] + U2_2[Unit 2.2: Event RunCreated dense snapshot] + U2_3[Unit 2.3: CommandContext + CLI consumers dense] + U2_4[Unit 2.4: Server runtime surfaces dense] + U2_5[Unit 2.5: /api/v1/runs/id/settings endpoint shape change] + U3_1[Unit 3.1: Atomic Layer-type move + Combine derive rewrite] + U4_1[Unit 4.1: Audit + lockdown] + + U1_1 --> U1_2 + U1_1 --> U1_3 + U1_2 --> U2_0 + U1_3 --> U2_0 + U2_0 --> U2_1 + U2_1 --> U2_2 + U2_1 --> U2_3 + U2_1 --> U2_4 + U2_1 --> U2_5 + U2_2 --> U3_1 + U2_3 --> U3_1 + U2_4 --> U3_1 + U2_5 --> U3_1 + U3_1 --> U4_1 +``` + +--- + +- [ ] **Unit 1.1: Introduce `WorkflowSettingsBuilder`; delete `enforce_server_authority` and siblings** + +**Goal:** Replace `materialize_settings_layer` free fn and `EffectiveSettingsLayers` struct with a `WorkflowSettingsBuilder` type in `fabro-config`. `enforce_server_authority`, `strip_owner_domains`, and the authoritative-subset list all disappear — their intent is expressed as (a) the normal `Combine` merge and (b) zeroing `server`/`cli`/`features` on the returned layer. **Precedence is unchanged from today.** Builder returns `SettingsLayer` in this unit (dense return comes in Unit 2.1) — this splits the two concerns (stripping policy vs. return-type change) into reviewable pieces. + +**Requirements:** R1, R2, R9. + +**Dependencies:** Prior plans 04-22-001 (active, must land first), 04-23-001-collapse, 04-23-001-command-context, 04-23-002. + +**Files:** +- Modify: `lib/crates/fabro-config/src/effective_settings.rs` +- Modify: `lib/crates/fabro-config/src/context.rs` (add `WorkflowSettingsBuilder` type alongside existing `WorkflowSettings`) +- Modify: `lib/crates/fabro-config/src/lib.rs` (re-exports) +- Modify: `lib/crates/fabro-server/src/run_manifest.rs` (`prepare_manifest` switches to builder) +- Modify: `lib/crates/fabro-cli/src/manifest_builder.rs` (scrutinize `ManifestBuilderInput` — fields `args_layer`/`user_layer` may be inlinable) +- Modify: `lib/crates/fabro-cli/src/commands/preflight.rs`, `lib/crates/fabro-cli/src/commands/run/create.rs`, `lib/crates/fabro-cli/src/commands/graph.rs`, `lib/crates/fabro-cli/src/commands/validate.rs` and any other site that constructs `EffectiveSettingsLayers` +- Test: `lib/crates/fabro-config/src/effective_settings.rs` (existing tests retarget the builder) + +**Approach:** +- Delete `EffectiveSettingsLayers` struct. +- Delete `materialize_settings_layer` free fn. +- Delete `enforce_server_authority` helper. +- Delete `strip_owner_domains` helper. +- Add `WorkflowSettingsBuilder` with five `SettingsLayer` slots and `.build()` returning `Result` (still sparse in this unit — keep temporarily). +- Builder merge follows **existing precedence**: `args.combine(workflow).combine(project).combine(user).combine(server.run_only).combine(defaults)` — server contributes only its `run.*` portion, at the lowest-client-layer position. +- Builder `build()` zeroes `server`/`cli`/`features` to `None` on the returned `SettingsLayer`. +- Centralize the "per-slot contribution" policy in the builder's docstring. No helper functions elsewhere carry this policy. +- Callers construct builder directly instead of `EffectiveSettingsLayers::new(...)` then `materialize_settings_layer(...)`. + +**Patterns to follow:** +- Builder shape mirrors the `uv`-style field-wise combine usage already present from 04-23-002. +- Error-accumulation idiom matches `ServerSettings::from_layer` in `context.rs` post-04-23-001-collapse. + +**Test scenarios:** +- Happy path: all five slots provided; result matches today's `materialize_settings_layer` output field-for-field for all `run.*` fields (precedence unchanged — same merged values come out). +- Happy path: empty/default slots (tests constructing `WorkflowSettingsBuilder::new().build()`) succeed. +- Edge case: `server.run.sandbox.provider = "daytona"` with `user.run.sandbox.provider = "local"` — **user wins**; resolved sandbox provider is `local`. Confirms client override of server default is preserved. +- Edge case: `server.run.sandbox.provider = "daytona"` with no user/project/workflow/args `run.sandbox.provider` — server's default flows through; resolved value is `daytona`. +- Edge case: `args.run.sandbox.provider = "local"` on top of a user config setting `"daytona"` — args win; resolved value is `local`. Confirms CLI flag override works. +- Edge case (bug fix, R9): `user.server.storage.root = "/tmp/user"` with `server.server.storage.root = None` — **resolved `server.*` is not present in the returned `SettingsLayer`** (zeroed at builder boundary). If a later unit reads storage root, it comes from `ServerSettings`, not from the materialized run settings. +- Edge case: `user.cli.output.format = "json"` — cli zeroed on returned layer. Runs can't see it. +- Edge case: `server.features.session_sandboxes = true` with `user.features.session_sandboxes = false` — features zeroed on returned layer. Runs can't see features. +- Error path: a `run.*` field with a bad value produces a `ResolveError` through the existing resolver machinery. Errors surface from `build()` as `Result<_, ResolveErrors>`. +- Integration: the existing `prepare_manifest_prefers_bundled_settings_without_duplication` test (run_manifest.rs ~line 990) is retargeted and the obsolete github-app-id assertion is deleted (it becomes vacuously unreachable — `server.*` is no longer in the materialized layer). + +**Verification:** +- `cargo nextest run -p fabro-config --workspace` passes. +- `rg "EffectiveSettingsLayers|materialize_settings_layer|enforce_server_authority|strip_owner_domains" lib/crates/` returns zero hits. +- The old test at `run_manifest.rs:1047` asserting snapshotted `server.integrations.github.app_id` no longer exists. +- New test exercises server `run.sandbox.provider` default being overridden by user. + +--- + +- [ ] **Unit 1.2: Collapse `resolve_*_from_file` indirection** + +**Goal:** Delete the `resolve_*_from_file` free-function stack. The per-namespace resolution logic moves inside fabro-config as implementation detail of the `*Builder` types (`pub(crate) fn from_layer` and friends); callers outside fabro-config switch to the **public** builder entry points (`load_from(&path)`, `from_toml(&source)`, or the multi-slot `WorkflowSettingsBuilder::new()...build()`). No top-level `pub fn resolve_*_from_file` remains at end of this unit. + +**Requirements:** R8. + +**Dependencies:** Unit 1.1. + +**Execution note:** Per review finding P1-5, the caller count across the workspace is larger than "dozens" — closer to 80–108 across 9 crates. If this unit's diff becomes unreviewable, split the caller migration by namespace (one sub-unit per `resolve_*` variant) and reconvene before landing. The goal is still a clean deletion of the free-fn stack by the end of Unit 2.4; Unit 1.2 does not have to land in one commit if splitting improves reviewability. + +**Files:** +- Modify/delete: `lib/crates/fabro-config/src/resolve/mod.rs` +- Modify: `lib/crates/fabro-config/src/resolve/cli.rs`, `lib/crates/fabro-config/src/resolve/server.rs`, `lib/crates/fabro-config/src/resolve/project.rs`, `lib/crates/fabro-config/src/resolve/features.rs`, `lib/crates/fabro-config/src/resolve/run.rs`, `lib/crates/fabro-config/src/resolve/workflow.rs` +- Modify: `lib/crates/fabro-config/src/lib.rs` (remove `resolve_*_from_file` re-exports) +- Modify: every call site of `resolve_*_from_file` in the workspace — audit via `rg "resolve_cli_from_file|resolve_server_from_file|resolve_project_from_file|resolve_features_from_file|resolve_run_from_file|resolve_workflow_from_file"` + +**Approach:** +- Audit each `resolve_*_from_file` caller (workspace grep finds ~80–108 sites across fabro-workflow, fabro-server, fabro-cli, fabro-install, and fabro-store). Categorize as: + - (a) Caller wants the whole dense facade (`ServerSettings`, `UserSettings`, `WorkflowSettings`) — retype to the facade. + - (b) Caller genuinely needs one resolved namespace (e.g., `ProjectNamespace` for path resolution). Expose a `pub(crate)` helper or a method on the Namespace type. Do not leave a `pub fn resolve_*_from_file` free fn. +- Delete `render_resolve_errors` in favor of `impl Display for ResolveErrors` newtype. Update consumers of the `"; "`-joined format. +- If the total caller migration is too large for one commit, split by namespace (one sub-PR per `resolve_*` variant). The end state — no public free fn named `resolve_*_from_file` — remains the same. + +**Execution note:** If a caller can't be cleanly migrated because the single-namespace-from-a-layer pattern is genuinely useful at its site, discuss with the reviewer rather than silently reinstating a free fn. The constraint is "no top-level public free fn named `resolve_*_from_file`" — `pub(crate)` helpers or Namespace associated functions are fine. + +**Patterns to follow:** +- 04-23-001-collapse established `ServerSettings::from_layer` / `UserSettings::from_layer` on dense facades. Extend the pattern if needed. +- The `ResolveErrors` newtype with `Display` impl mirrors the error-wrapping pattern used by other `fabro-*` crates (see `fabro-workflow::Error` for a parallel example). + +**Test scenarios:** +- Happy path: existing consumers of `resolve_*_from_file` produce the same resolved value through their new call path. +- Happy path: `ResolveErrors::to_string()` produces the same `"; "`-joined output that `render_resolve_errors` did (capture a snapshot for an invalid fixture). +- Error path: a resolve failure propagates through `from_layer` / builder `build()` with no loss of error detail. + +**Verification:** +- `rg "resolve_cli_from_file|resolve_server_from_file|resolve_project_from_file|resolve_features_from_file|resolve_run_from_file|resolve_workflow_from_file|render_resolve_errors" lib/crates/` returns zero hits (or only hits internal to `fabro-config` as `pub(crate)` helpers). +- Error-message format (fixture diff) unchanged. + +--- + +- [ ] **Unit 1.3: Delete thin wrappers (free-function deletions only)** + +**Goal:** Remove one-liner free functions (`parse_settings_layer`, `apply_builtin_defaults`, `defaults_layer`, `resolve_storage_root`). Replace internal usage sites with inlined calls. The accompanying `FromStr` / `From` ergonomic impls are **deferred to Unit 3.1** where `SettingsLayer` moves into fabro-config and the impls naturally become `pub(crate)`. Landing them earlier would create a transitional window in which they are usable from any external crate — the plan never wants to open that window. + +**Requirements:** R8 (memory `feedback_avoid_oneliner_free_functions`). + +**Dependencies:** Unit 1.1. + +**Files:** +- Delete: `lib/crates/fabro-config/src/parse.rs` (its one function `parse_settings_layer` is replaced by inline `toml::from_str` at the handful of internal callers; `FromStr` impl follows in Unit 3.1). +- Modify: `lib/crates/fabro-config/src/defaults.rs` (delete `defaults_layer()` and `apply_builtin_defaults()` free fns — inline `DEFAULTS_LAYER.combine(...)` into builder `build()`) +- Modify: `lib/crates/fabro-config/src/effective_settings.rs` or wherever the builder lives (inline the one `apply_builtin_defaults` call into `build()`) +- Delete: `resolve_storage_root` free fn — replace with a method on `ServerSettings` (inherent or via `ServerSettingsBuilder`; implementer's choice). +- Modify: `lib/crates/fabro-config/src/lib.rs` (remove re-exports for deleted items) +- Modify: all call sites of `parse_settings_layer` in fabro-config's own code and tests — replace with `toml::from_str`. External call sites of `parse_settings_layer` (if any remain across the workspace) either migrate to a public builder entry (`SettingsLayer::load_from` is not available because the type is `pub(crate)`-bound for Unit 3.1) or are absorbed into Unit 2.x's consumer retypes. + +**Approach:** +- Inline the one-line wrappers at their internal callers. No new trait impls in this unit. +- `DEFAULTS_LAYER` becomes a `pub(crate)` `LazyLock` item, referenced directly from the builder. No accessor function. +- **Do not add `FromStr` or `From` impls in this unit.** They are scheduled in Unit 3.1 (Files list: "`lib/crates/fabro-config/src/layers/settings.rs` gains `pub(crate) impl FromStr for SettingsLayer` and `pub(crate) impl From for SettingsLayer` for the six sub-Layer types; builder's `pub(crate)` setters use `impl Into`"). Deferring keeps Unit 1.3 purely "delete the wrappers" and sidesteps the transitional-visibility concern. + +**Patterns to follow:** +- Inline-deletion idiom: where the wrapper added nothing, replace calls with the direct expression (`toml::from_str(s)`, `DEFAULTS_LAYER.combine(layer)`, etc.). + +**Test scenarios:** +- Happy path: existing TOML parse tests and defaults tests continue to pass after inlining; no behavior change. +- Happy path: `ServerSettings::storage_root_path()` (or the chosen replacement for `resolve_storage_root`) returns the same resolved `PathBuf` the free fn produced. +- Error path: malformed TOML still produces the same `ParseError` through the inlined `toml::from_str` call. + +**Verification:** +- `rg "\bparse_settings_layer\b|\bapply_builtin_defaults\b|\bdefaults_layer\b|\bresolve_storage_root\b" lib/crates/` returns zero hits. +- Existing TOML parse tests pass unchanged. + +--- + +- [ ] **Unit 2.0: Move dense bundle types (`WorkflowSettings`, `UserSettings`, `ServerSettings`) into `fabro-types`** + +**Goal:** Relocate `WorkflowSettings`, `UserSettings`, `ServerSettings` struct definitions from `fabro-config::context` to `fabro-types`, preserving every derive, field, and inherent method today's consumers rely on. Keep construction (`from_layer`-style functions) in `fabro-config` as sibling `WorkflowSettingsBuilder` / `UserSettingsBuilder` / `ServerSettingsBuilder` types. Every workspace call site switches `fabro_config::{UserSettings, ServerSettings, WorkflowSettings}` → `fabro_types::{...}`. + +**Requirements:** R7 (dense bundles live in fabro-types). + +**Dependencies:** Units 1.1, 1.2, 1.3 (so the internal fabro-config machinery is already cleaned up before the public type surface moves). Must complete before Unit 2.1. + +**Execution note:** This is an atomic cross-crate move (same shape as Unit 3.1) and must land in one PR. `fabro-types` cannot depend on `fabro-config`, so no bridge re-exports are available — all call sites update in the same commit that moves the types. + +**Files:** +- Create: `lib/crates/fabro-types/src/dense/mod.rs` (or similar — `lib/crates/fabro-types/src/settings/bundles.rs` also works). Contains the three struct definitions plus preserved derives. +- Modify: `lib/crates/fabro-config/src/context.rs` — delete the three struct definitions; keep any constructor impls that belong here, but they now live on sibling `*Builder` types in `lib/crates/fabro-config/src/builders.rs` (new module) rather than as inherent impls on the moved structs. +- Create: `lib/crates/fabro-config/src/builders.rs` (new module) housing `WorkflowSettingsBuilder`, `UserSettingsBuilder`, `ServerSettingsBuilder`. Each exposes a **public** construction API (`load_from(&Path)`, `from_toml(&str)`, plus `WorkflowSettingsBuilder::new()`'s multi-slot chain) and a **`pub(crate)`** `from_layer(&SettingsLayer) -> Result<...>` for internal use. See Key Technical Decisions for the full per-builder API. +- Modify: `lib/crates/fabro-config/src/lib.rs` — export the new `*Builder` types publicly. **Do not re-export `WorkflowSettings` / `UserSettings` / `ServerSettings` from fabro-config** — callers import those directly from `fabro_types`. Enforcing one canonical import path for each dense type prevents drift between `fabro_config::UserSettings` and `fabro_types::UserSettings` from ever materializing again. +- Modify: every call site of `fabro_config::UserSettings`, `fabro_config::ServerSettings`, `fabro_config::WorkflowSettings` — switch to `fabro_types::...`. Workspace grep: `rg "fabro_config::(UserSettings|ServerSettings|WorkflowSettings)\b"`. Expected end-state: zero hits. +- Modify: every external call site of `UserSettings::from_layer` / `ServerSettings::from_layer` / `WorkflowSettings::from_layer` — switch to the public entry points on the builders: `UserSettingsBuilder::load_from(&path)?`, `ServerSettingsBuilder::load_from(&path)?`, `ServerSettingsBuilder::from_toml(source)?`, or `WorkflowSettingsBuilder::new()...build()?`. **The `from_layer` form stays `pub(crate)`** and is used only by fabro-config's own internal construction and tests. +- Verify preserved invariants: see "Approach" below. + +**Approach:** +- **Preserve every derive.** `WorkflowSettings` today in `fabro-config/src/context.rs` derives `Debug, Clone, PartialEq, Serialize`. The moved definition must add **`Deserialize`** — `RunSpec.settings` (in fabro-types) derives `Deserialize`, so after the Unit 2.1 retype `RunSpec` contains `WorkflowSettings`, and deserialization of `RunSpec` requires `WorkflowSettings: Deserialize`. Same audit for `UserSettings` and `ServerSettings` — confirm `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq` are all present as needed by every consumer. +- **Preserve inherent read-only methods.** `WorkflowSettings::combined_labels(&self) -> HashMap` exists today (returns the union of `project.metadata`, `workflow.metadata`, `run.metadata`). Move the inherent impl to fabro-types alongside the struct — it's a pure data read, not a construction operation, so it stays with the vocabulary type. Audit `UserSettings` and `ServerSettings` for similar inherent read-only methods and move them too. Constructor methods (`from_layer`) do NOT move — they go on the sibling `*Builder` types. +- **The three struct definitions are pure data.** No `Combine`, no `*Layer`, no merge-specific collection types. They depend only on the resolved `*Namespace` types (which already live in fabro-types) plus `FeaturesNamespace`. +- Update `Cargo.toml` for any crate that depended on fabro-config *only* for the dense types — it can drop fabro-config if it no longer needs layer types or builders. + +**Patterns to follow:** +- The move mirrors Unit 3.1's pattern (one atomic cross-crate relocation, no bridges) but scopes to just three struct definitions plus their inherent read-only impls. Much smaller than Unit 3.1. + +**Test scenarios:** +- Happy path: `cargo build --workspace` succeeds. +- Happy path: every existing test passes unchanged — no behavior change, pure relocation. +- Compile-time: `fabro_types::WorkflowSettings` compiles from every consumer crate. +- Compile-time: `let spec: RunSpec = serde_json::from_value(v)?` compiles (confirms `WorkflowSettings: Deserialize`). +- Compile-time: `workflow_settings.combined_labels()` compiles from every consumer — inherent impl survived the move. +- Compile-time: `fabro_config::WorkflowSettings::from_layer(&layer)` no longer compiles (method moved to `WorkflowSettingsBuilder::from_layer`); every call site is updated. + +**Verification:** +- `rg "fabro_config::(UserSettings|ServerSettings|WorkflowSettings)\b" lib/crates/` returns zero hits. +- `rg "WorkflowSettings::from_layer\b|UserSettings::from_layer\b|ServerSettings::from_layer\b" lib/crates/` returns zero hits in production code. +- `rg "fabro_types::(UserSettings|ServerSettings|WorkflowSettings)\b" lib/crates/` returns expected hits (consumers). +- `cargo nextest run --workspace` passes. + +--- + +- [ ] **Unit 2.1: Builder returns dense `WorkflowSettings`; retype `RunSpec` / `RunCreatedProps` / `PreparedManifest`; migrate preflight and replay** + +**Goal:** `WorkflowSettingsBuilder::build()` returns `WorkflowSettings` (dense) instead of `SettingsLayer` (sparse). `RunSpec.settings` and `RunCreatedProps.settings` in `fabro-types` retype from `SettingsLayer` to `WorkflowSettings`. `PreparedManifest.settings` follows. This is the unit that closes the "sparse type leaks past config" hole for everything related to runs, including the event-replay reconstruction path. + +**Requirements:** R5 (PreparedManifest / RunSpec / RunCreatedProps retypes to dense). + +**Dependencies:** Unit 1.1, Unit 1.2, **Unit 2.0** (dense bundles must already be in fabro-types for `RunSpec.settings: WorkflowSettings` to compile). + +**Files:** +- Modify: builder `build()` signature and body +- Modify: `lib/crates/fabro-types/src/run.rs:54` (`RunSpec.settings: SettingsLayer` → `WorkflowSettings`) +- Modify: `lib/crates/fabro-types/src/run_event/run.rs:12` (`RunCreatedProps.settings: SettingsLayer` → `WorkflowSettings`) +- Modify: `lib/crates/fabro-workflow/src/event.rs:1513` (`event_body_from_event` deserializer — target type is now `WorkflowSettings` by field type alone; verify the `.expect()` path stays correct and consider switching to a structured error) +- Modify: `lib/crates/fabro-store/src/run_state.rs:49-65` (RunSpec reconstruction from the event `settings` field — production replay path, not just test fixtures; target type is dense) +- Modify: `lib/crates/fabro-store/src/slate/mod.rs` (any RunSpec construction on this path) +- Modify: `lib/crates/fabro-server/src/run_manifest.rs` (`PreparedManifest.settings: SettingsLayer` → `WorkflowSettings`; consumers access `.project`, `.workflow`, `.run` fields directly; delete the assertion at ~line 1047 that reads `resolved_server.integrations.github.app_id` from the materialized layer) +- Modify: `lib/crates/fabro-workflow/src/run_materialization.rs` (consumer of materialized settings) and every site that called `resolve_run_from_file(&prepared.settings)` or equivalent on a `RunSpec.settings` — workspace grep finds these (see Unit 1.2 scope) +- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` (align `create_run` signature from 04-23-001-collapse with dense `WorkflowSettings`) +- Modify: **`apply_storage_dir_override` migration** across its six call sites (flagged by P1-6): `lib/crates/fabro-config/src/user.rs:67` (definition — delete), `lib/crates/fabro-cli/src/user_config.rs:30`, `lib/crates/fabro-cli/src/command_context.rs:189` and `:250`, `lib/crates/fabro-cli/src/commands/install.rs:1467` and `:1780`, `lib/crates/fabro-server/src/serve.rs:9` and `:194`. Replace each with the new `ServerSettings::with_storage_override(self, path: &Path) -> ServerSettings` post-resolve method defined on the dense type in `fabro-types` (see Key Technical Decisions). Call-site shape: `ServerSettingsBuilder::load_from(&path)?.with_storage_override(&dir)`. + +**Approach:** +- Builder `build()` performs merge → strip → resolve internally, returns `Result`. +- Field retypes on `RunSpec` and `RunCreatedProps` propagate through serde: emitter side (Unit 2.2) and deserializer side (event_body_from_event at fabro-workflow/src/event.rs:1513) both see `WorkflowSettings` naturally once the field types agree. No hand-rolled adapter required. +- `PreparedManifest.settings` retype lets downstream consumers replace `resolve_*_from_file(&prepared.settings)` with `prepared.settings.run` / `prepared.settings.project` / `prepared.settings.workflow` direct access. +- Confirm the 04-23-001-collapse `create_run(storage_root: PathBuf)` signature still works — dense migration doesn't change it. +- `apply_storage_dir_override` — migrated to `ServerSettings::with_storage_override(self, path: &Path) -> ServerSettings`, an inherent method on the dense type (lives in fabro-types, alongside the struct — coherence-compliant). Callers chain it after resolution: `ServerSettingsBuilder::load_from(&path)?.with_storage_override(&dir)`. The helper no longer operates on `SettingsLayer` and no longer has a free-fn form. + +**Patterns to follow:** +- Dense facade access pattern already established by 04-23-001-collapse for `ServerSettings::from_layer` / `UserSettings::from_layer`. +- RunSpec reconstruction at `fabro-store/src/run_state.rs:49` follows the existing event-deserialization convention — retype the target, the serde roundtrip handles the rest. + +**Test scenarios:** +- Happy path: `PreparedManifest` built from a manifest resolves identically (field-for-field in the dense namespaces) to the pre-refactor flow. +- Happy path: preflight sandbox check produces a correct `SandboxProvider` from `prepared.settings.run.sandbox.provider` (dense access path). +- Happy path: event replay — a `RunCreated` event round-trips through `event_body_from_event` into `RunCreatedProps { settings: WorkflowSettings { ... }}` without panicking. +- Happy path: `RunSpec` reconstruction at `fabro-store/src/run_state.rs:52` produces a `RunSpec` with typed `WorkflowSettings`, readable by downstream consumers. +- Happy path: CLI `fabro server start --storage-dir /tmp/foo` still resolves storage correctly after `apply_storage_dir_override` migration. +- Edge case: a preflight run with a goal from the manifest (`manifest.goal`) correctly overrides the run.goal (test already exists; update to assert against dense field). +- Integration: `prepare_manifest_prefers_bundled_settings_without_duplication` test at `run_manifest.rs:990` — asserts `run.prepare.commands == ["workflow-setup"]` still holds via `prepared.settings.run.prepare.commands`. The `server.integrations` assertion is deleted. +- Error path: builder propagates `ResolveErrors` through `prepare_manifest` as it does today. + +**Verification:** +- `rg "PreparedManifest" lib/crates/` shows the field is `WorkflowSettings`. +- `rg "RunSpec" lib/crates/fabro-types/src/run.rs` shows `settings: WorkflowSettings`. +- `rg "RunCreatedProps" lib/crates/fabro-types/src/run_event/run.rs` shows `settings: WorkflowSettings`. +- `rg "resolve_run_from_file\(&prepared" lib/crates/` returns zero hits. +- `rg "apply_storage_dir_override" lib/crates/` returns zero hits (or only hits on its replacement method). +- `cargo nextest run -p fabro-server -p fabro-workflow -p fabro-store` pass. + +--- + +- [ ] **Unit 2.2: `Event::RunCreated.settings` emits dense-shaped JSON (field type already dense after Unit 2.1)** + +**Goal:** Every emitter of `Event::RunCreated` constructs the event from a dense `WorkflowSettings` (which after Unit 2.1 is what `RunCreatedProps.settings` already holds). The wire JSON naturally becomes dense-shaped. Every snapshot fixture and every production emitter is enumerated and updated. + +**Requirements:** R6. + +**Dependencies:** Unit 2.1. + +**Rationale for separating from Unit 2.1:** Unit 2.1 retypes the field; Unit 2.2 catalogs every production emitter and every snapshot fixture. These are different audit surfaces — reviewers can focus on "is every emitter enumerated?" separately from "is the type sound?" + +**Files (comprehensive emitter + snapshot list):** +- Modify: `lib/crates/fabro-workflow/src/event.rs` — `Event::RunCreated.settings` field stays `serde_json::Value` on the wire; add a docstring noting the dense-shape contract. +- Modify: every production emitter of `Event::RunCreated`. Workspace grep `rg -l 'Event::RunCreated' lib/` currently finds ~10 files. Enumerate all: `lib/crates/fabro-workflow/src/event.rs`, `lib/crates/fabro-workflow/src/test_support.rs`, `lib/crates/fabro-workflow/src/runtime_store.rs`, `lib/crates/fabro-workflow/src/run_lookup.rs`, `lib/crates/fabro-workflow/src/pipeline/retro.rs`, `lib/crates/fabro-workflow/src/pipeline/persist.rs`, `lib/crates/fabro-workflow/src/pipeline/pull_request.rs`, `lib/crates/fabro-workflow/src/operations/rebuild_meta.rs`, `lib/crates/fabro-cli/src/commands/dump.rs`. Each site now passes a `WorkflowSettings` to `serde_json::to_value`. +- Modify: `lib/crates/fabro-store/src/run_state.rs` (event-JSON-shape test fixtures; existing `settings: SettingsLayer::default()` fixture becomes `settings: WorkflowSettings::default()` serialized via `serde_json::to_value`). +- Modify: `lib/crates/fabro-store/src/slate/mod.rs` (same). +- Modify: every insta snapshot and serde fixture under `lib/crates/fabro-workflow/`, `lib/crates/fabro-store/`, `lib/crates/fabro-cli/tests/`, `lib/crates/fabro-server/tests/` that captures a `run.created` event. Enumerate: `cargo insta pending-snapshots` before reviewing; each stale snapshot must be re-accepted against the dense shape. Audit `lib/crates/fabro-cli/tests/it/cmd/attach.rs`, `lib/crates/fabro-cli/tests/it/cmd/logs.rs`, `lib/crates/fabro-cli/tests/it/cmd/run.rs` for JSON-shape assertions. + +**Approach:** +- Emitters pass `serde_json::to_value(&workflow_settings)?` where they used to pass `serde_json::to_value(&settings_layer)?`. +- Wire field stays `serde_json::Value` to avoid schema churn at the `Event` enum level. +- Document at the `RunCreated` variant that the shape is `WorkflowSettings` (dense). +- Delete any code that was specifically relying on the sparse-layer's skip-if-none serialization to infer "was this explicitly set?" — dense snapshot means defaults are concrete. + +**Patterns to follow:** +- Event-shape evolution: no corresponding API surface (the CLI's `logs --json` passes through whatever the store returns). + +**Test scenarios:** +- Happy path: a run's `Event::RunCreated` records, when deserialized, yield a `WorkflowSettings` whose fields match the values the run actually executed with. +- Happy path: an event written before the refactor is not required to read back (greenfield, no production data) — if it did exist, migration is out of scope. This is noted in Risks. +- Integration: `attach` / `logs` JSON-shape tests assert on the new dense field names (e.g., `run.execution.mode` appears concretely rather than as `Option`-stripped). +- Edge case: fields unset by the user appear as their resolved default values in the snapshot, not as missing. + +**Verification:** +- `rg "Event::RunCreated" lib/crates/ -A 3` shows every emitter passes `WorkflowSettings`-derived JSON. +- `cargo nextest run -p fabro-store -p fabro-workflow -p fabro-cli` passes with updated fixtures. + +--- + +- [ ] **Unit 2.3: `CommandContext` + CLI consumers take dense types** + +**Goal:** `CommandContext.machine_settings: SettingsLayer` retyped; `local_server.rs` helpers take `&ServerSettings`; `user_config.rs` returns dense types. The carve-out docstring in `local_server.rs` retires. + +**Requirements:** R2, R5. + +**Dependencies:** Unit 2.1 (the dense facades must already support every field the CLI reads). + +**Files:** +- Modify: `lib/crates/fabro-cli/src/command_context.rs` (retype `machine_settings`; decide between splitting into two fields or collapsing to already-present `UserSettings` plus a new `ServerSettings` field — deferred open question) +- Modify: `lib/crates/fabro-cli/src/local_server.rs` (all helpers take `&ServerSettings`; remove the "only generic CLI lifecycle surface allowed to read `[server.*]` settings" docstring) +- Modify: `lib/crates/fabro-cli/src/server_client.rs` (`connect_server_with_settings(&SettingsLayer, ...)` → `connect_server_with_settings(&ServerSettings, ...)`) +- Modify: `lib/crates/fabro-cli/src/user_config.rs` (`load_settings() -> SettingsLayer` becomes either `load_settings() -> (ServerSettings, UserSettings)` or is split into two loader functions) +- Modify: all `commands/` files that pull `storage_dir`, `bind_request`, `auth_methods`, `config_log_level` off a `SettingsLayer` + +**Approach:** +- `local_server::storage_dir(&ServerSettings)` reads `server_settings.server.storage.root` directly — no `.as_ref().and_then(...)` chain. +- `config_log_level(&ServerSettings) -> Option` reads `server_settings.server.logging.level` directly. +- `auth_methods(&ServerSettings) -> Vec` reads `server_settings.server.auth.methods` directly. +- `bind_request(&ServerSettings, cli_override: Option<&str>) -> Result` reads the typed fields directly. +- `CommandContext::server()` connects using dense `ServerSettings` for target/storage. +- Retire the docstring at the top of `local_server.rs` that claims exception status — there's no exception anymore; the module just reads typed fields like anywhere else. + +**Patterns to follow:** +- Dense-facade-access pattern from Unit 2.1. +- 04-23-001-command-context established `CommandContext` as the centralized invocation-plumbing struct; extend its field types. + +**Test scenarios:** +- Happy path: `CommandContext` created in the default mode exposes expected server target via `ServerSettings`. +- Happy path: `storage_dir_override_only_changes_storage_root_in_merged_settings` test in `command_context.rs` migrates to asserting against `ServerSettings.server.storage.root` rather than a `SettingsLayer`. +- Edge case: user's `~/.fabro/settings.toml` with only `[cli]` set — `UserSettings` resolves correctly; `ServerSettings` gets defaults. +- Integration: CLI command that reads printer verbosity (from `UserSettings`) and connects to a server (via `ServerSettings`) exercises both. + +**Verification:** +- `lib/crates/fabro-cli/src/local_server.rs` has no `.as_ref().and_then(...)` chains against settings. +- `lib/crates/fabro-cli/src/local_server.rs` module docstring no longer claims carve-out status. +- `rg "settings:\s*&?SettingsLayer" lib/crates/fabro-cli/` returns zero hits in production code. + +--- + +- [ ] **Unit 2.4: Server runtime surfaces take dense types** + +**Goal:** `replace_settings`, `web_auth`, `serve::resolve_bind_request_from_settings` migrate from `SettingsLayer` to dense types. + +**Requirements:** R5. + +**Dependencies:** Unit 2.1. + +**Files:** +- Modify: `lib/crates/fabro-server/src/server.rs` (`replace_settings(SettingsLayer)` → `replace_server_settings(ServerSettings)`; update `AppState` helper signatures) +- Modify: `lib/crates/fabro-server/src/web_auth.rs:907` (`settings: SettingsLayer` parameter → `server: &ServerSettings`) +- Modify: `lib/crates/fabro-server/src/serve.rs` (`resolve_bind_request_from_settings(&SettingsLayer, ...)` → `resolve_bind_request_from_server(&ServerSettings, ...)`) +- Modify: `lib/crates/fabro-server/tests/it/helpers.rs` (test harness `settings: SettingsLayer` → `ServerSettings`) +- Modify: `lib/crates/fabro-server/tests/it/api/routing.rs`, `lib/crates/fabro-server/tests/it/api/settings.rs` (test fixtures) +- Modify: `lib/crates/fabro-server/src/install.rs` if it passes `SettingsLayer` to any of the above + +**Approach:** +- Hot-swap flow: the server's loader produces a `ServerSettings` on config refresh; `replace_server_settings` atomically swaps it in `AppState`. +- Web-auth reads `server.auth` directly from the dense type. +- Bind-request resolution reads `server.listen` and (where applicable) `server.web` directly. + +**Patterns to follow:** +- Existing `AppState::server_settings() -> Arc` pattern introduced by 04-22-001. + +**Test scenarios:** +- Happy path: settings reload via `replace_server_settings` reflects updated auth methods on subsequent requests. +- Happy path: bind-request resolution with a CLI override produces the same `BindRequest` as before. +- Edge case: incompletely-specified server config yields the expected error through the typed path. +- Integration: a full server-start → request → settings-reload loop exercises the retyped surfaces. + +**Verification:** +- `rg "SettingsLayer" lib/crates/fabro-server/src/` returns zero hits in production code. +- `cargo nextest run -p fabro-server` passes. + +--- + +- [ ] **Unit 2.5: `/api/v1/runs/{id}/settings` endpoint + OpenAPI + web UI migrate to dense shape** + +**Goal:** The `/api/v1/runs/{id}/settings` endpoint returns dense `WorkflowSettings` JSON. OpenAPI schema renamed from the old sparse run-settings name to `RunSettings`. TypeScript client regenerates. Web UI route updates in lockstep so users continue to see a correct settings snapshot. + +**Requirements:** R11. + +**Dependencies:** Unit 2.1 (`RunSpec.settings` is already `WorkflowSettings`). + +**Files:** +- Modify: `docs/api-reference/fabro-api.yaml` — `/api/v1/runs/{id}/settings` endpoint response (line ~1374) now references `RunSettings` schema. The old sparse run-settings schema (line ~5448) is renamed to `RunSettings` or replaced with a `$ref` to a shared `WorkflowSettings`-shaped schema. Update docstring accordingly. +- Modify: `lib/crates/fabro-api/build.rs` — add a `with_replacement` entry for the `RunSettings`/`WorkflowSettings` schema pointing at the canonical `fabro_types::WorkflowSettings`, so progenitor uses the existing Rust type rather than generating a parallel one. Per CLAUDE.md "API type ownership." +- Create: `lib/crates/fabro-api/tests/workflow_settings_round_trip.rs` — type-identity and JSON-parity test for the new `with_replacement`. Modeled on the existing `lib/crates/fabro-api/tests/server_settings_round_trip.rs`. Per CLAUDE.md: "For every new `with_replacement(...)`, add a `fabro-api` test that proves type identity and JSON parity with the OpenAPI schema." +- Modify: `lib/crates/fabro-server/src/server.rs` — the route handler for `/api/v1/runs/{id}/settings` returns `WorkflowSettings` (matches `run_spec.settings` directly after Unit 2.1). +- Modify: `lib/packages/fabro-api-client/` — regenerate TypeScript Axios client (`bun run generate`). +- Modify: `apps/fabro-web/app/routes/run-settings.tsx` — consume `RunSettings` (dense) instead of the old sparse run-settings schema. If the renderer is a generic JSON viewer, no changes beyond the type rename. If the renderer pattern-matches sparse field shapes, adapt to the dense shape. +- Run: `scripts/refresh-fabro-spa.sh` — regenerate bundled SPA after web UI change (CLAUDE.md mandate). + +**Approach:** +- OpenAPI schema rename from the old sparse run-settings name to `RunSettings`. Update the description to reflect "the resolved `WorkflowSettings` snapshot captured at run creation." +- `build.rs` `with_replacement` makes the new `RunSettings` schema map to the existing `fabro_types::WorkflowSettings`, avoiding a duplicate API-only type. Parity test (`workflow_settings_round_trip.rs`) validates the two types serialize/deserialize identically and reject the same invalid JSON, matching the pattern set by `server_settings_round_trip.rs`. +- Server handler reads `run_spec.settings` and returns it as the response body — serde handles the dense JSON emission. +- TypeScript client regeneration picks up the new name and shape. +- Web UI renderer is a JSON display; confirm it renders the new shape without relying on `Option`-aware logic. + +**Patterns to follow:** +- OpenAPI-first workflow per CLAUDE.md — edit the spec first, then `cargo build -p fabro-api`, then `cd lib/packages/fabro-api-client && bun run generate`. +- CLAUDE.md "API type ownership" section on `with_replacement` and parity tests. +- Parity-test shape: `lib/crates/fabro-api/tests/server_settings_round_trip.rs` — mirror this for the new test. +- `scripts/refresh-fabro-spa.sh` before committing any TypeScript change. + +**Test scenarios:** +- Happy path: GET `/api/v1/runs/{id}/settings` returns a JSON object matching the dense `WorkflowSettings` shape — `run.execution.mode` is a concrete string, `project.directory` is a concrete string, etc. +- Happy path: the new `workflow_settings_round_trip.rs` parity test passes — asserts that a `fabro_types::WorkflowSettings` serialized to JSON matches the OpenAPI `RunSettings` schema shape, and that a `fabro_api::types::RunSettings` deserialized from that JSON equals the original. +- Happy path: the conformance test (`cargo nextest run -p fabro-server`) still passes after the schema rename and handler adjustment. +- Integration: web UI displays the run's settings correctly after the change. Manual check via dev server (fabro-web + fabro-server running together). +- Regression: the OpenAPI diff between the old and new schema is documented in the PR description so reviewers can assess downstream impact. + +**Verification:** +- Repo-wide grep for the retired sparse run-settings schema name returns zero hits (schema rename is complete across spec, Rust client, TypeScript client, web UI). +- `cargo nextest run -p fabro-api -p fabro-server` passes (conformance test catches spec/router drift; parity test catches type-identity drift). +- `scripts/refresh-fabro-spa.sh` succeeds (confirms web UI builds after the type change). + +--- + +- [ ] **Unit 3.1: Atomic cross-crate move of `*Layer` types, `Combine` trait (with macro rewrite), and merge mechanism** + +**Goal:** One atomic PR that moves the sparse/mechanism side of the crate boundary into place. (The dense side already moved in Unit 2.0.) + +Two relocations in one commit: +1. Move all sparse `*Layer` types, the `Combine` trait, `MergeMap`/`ReplaceMap`/`StickyMap`, and `SpliceArray` from `fabro-types` into `fabro-config`. Make `SettingsLayer`, `Combine`, and `SpliceArray` `pub(crate)`. `MergeMap` / `ReplaceMap` / `StickyMap` stay `pub` (CLI adapters construct `ReplaceMap` values). Sub-Layer types (`RunLayer`, `CliLayer`, …) remain `pub`. +2. Rewrite the `fabro-macros::Combine` derive to emit an absolute trait path (`::fabro_config::layers::Combine`). The derive is only usable for types defined inside fabro-config because `Combine` is `pub(crate)` — this is acceptable because every `#[derive(Combine)]` site is inside fabro-config after the Layer types move. Document the scoping in the macro's rustdoc. + +**Requirements:** R3, R4, R7 (Layer-side portion; dense-bundle portion was satisfied by Unit 2.0), R10. + +**Dependencies:** Units 2.0, 2.1–2.5 (all production consumers are on dense types; dense bundles already moved to fabro-types; only sub-Layer types still travel cross-crate, and those travel only within CLI override adapters). + +**Files:** +- Create: `lib/crates/fabro-config/src/layers/mod.rs` with modules `cli`, `server`, `project`, `workflow`, `run`, `features`, `settings` (for `SettingsLayer`), `combine`, `maps`, `splice_array` +- Create: `lib/crates/fabro-config/src/layers/cli.rs`, `.../server.rs`, `.../project.rs`, `.../workflow.rs`, `.../run.rs`, `.../features.rs`, `.../settings.rs`, `.../combine.rs`, `.../maps.rs`, `.../splice_array.rs` +- Modify: `lib/crates/fabro-types/src/settings/cli.rs` — remove `CliLayer` and sub-layers; keep `CliNamespace` and value enums +- Modify: `lib/crates/fabro-types/src/settings/server.rs` — keep `ServerNamespace` + sub-settings + strategy/method enums; remove `ServerLayer` + sub-layers +- Modify: `lib/crates/fabro-types/src/settings/project.rs` — keep `ProjectNamespace`; remove `ProjectLayer` +- Modify: `lib/crates/fabro-types/src/settings/workflow.rs` — keep `WorkflowNamespace`; remove `WorkflowLayer` +- Modify: `lib/crates/fabro-types/src/settings/run.rs` — keep `RunNamespace` + resolved sub-settings + value enums (`ApprovalMode`, `RunMode`, `HookType`, `McpTransport`, `TlsMode`, `MergeStrategy`, `WorktreeMode`, `DaytonaSnapshotSettings` resolved form, etc.); remove `RunLayer` + sub-layers + `HookEntry`/`HookAgentMarker`/`HookTlsMode`/`DaytonaNetworkLayer`/etc. + `ModelRefOrSplice`/`StringOrSplice` +- Modify: `lib/crates/fabro-types/src/settings/features.rs` — keep `FeaturesNamespace`; remove `FeaturesLayer` +- Delete: `lib/crates/fabro-types/src/settings/layer.rs` +- Delete: `lib/crates/fabro-types/src/settings/combine.rs` +- Delete: `lib/crates/fabro-types/src/settings/maps.rs` +- Delete: `lib/crates/fabro-types/src/settings/splice_array.rs` +- Modify: `lib/crates/fabro-types/src/settings/mod.rs` — remove re-exports for moved Layer types. +- Rewrite: `lib/crates/fabro-macros/src/lib.rs:177-182` — the `Combine` derive macro emits `::fabro_config::layers::Combine::combine(self.#name, other.#name)` and `impl ... ::fabro_config::layers::Combine for ...`. Verify against `uv`'s equivalent pattern. +- Add: `fabro-config/Cargo.toml` gains a `fabro-macros` dependency (currently only `fabro-types` has it; after this unit, the `*Layer` structs that derive `Combine` live in fabro-config, so fabro-config needs the macros crate). +- Remove: `fabro-types/Cargo.toml` may drop `fabro-macros` if no remaining type in fabro-types uses any `fabro-macros` derive. Audit during implementation. +- Modify: `lib/crates/fabro-config/src/lib.rs` — re-export `*Layer` sub-types as `pub`; `SettingsLayer`, `Combine`, `SpliceArray` stay `pub(crate)`. +- Add: `lib/crates/fabro-config/src/layers/settings.rs` gains `pub(crate) impl FromStr for SettingsLayer` (deferred from Unit 1.3) plus `pub(crate) impl From for SettingsLayer` for the six sub-Layer types. Update `WorkflowSettingsBuilder`'s `pub(crate)` internal setters (`args_layer`, `user_layer`, …) to take `impl Into` at this point — now safe because the type is in fabro-config and all these impls are `pub(crate)`. +- Modify: `lib/crates/fabro-config/tests/*.rs` — existing integration tests (e.g., `resolve_run.rs`, `resolve_cli.rs`) name `SettingsLayer` directly. Integration tests compile as external crates and cannot access `pub(crate)` items. These tests must either (a) move into `#[cfg(test)] mod tests` blocks inside `lib/crates/fabro-config/src/**`, or (b) be supported by a `test-support` feature on fabro-config that re-exports `SettingsLayer` as `pub` under `#[cfg(feature = "test-support")]`. Pick (a) for simplicity unless the test setup is genuinely re-used across multiple integration tests. +- Modify: every crate that previously imported `fabro_types::settings::XxxLayer` — update to `fabro_config::XxxLayer`. Workspace-wide sweep via `rg "fabro_types::settings::(Settings|Cli|Run|Project|Workflow|Server|Features)Layer"` and sibling queries for `Combine`, `MergeMap`, `ReplaceMap`, `StickyMap`, `SpliceArray`. +- Modify: `Cargo.toml` for every crate that now needs `fabro-config` because it lost access to a Layer type from `fabro-types` (primarily `fabro-cli` adapters). Confirm `fabro-types/Cargo.toml` gains no dependency on `fabro-config`. + +**Approach:** +- **One atomic PR.** No bridge re-exports — `fabro-types` cannot depend on `fabro-config`, so partial transitions via re-exports would create a circular dep. Mechanical find-replace across use statements. +- For each affected file in `fabro-types/src/settings/`: delete the `*Layer` struct, delete its `serde` derive plumbing that's Layer-specific, delete any Layer-only helper impls (`Combine`). Keep everything else. +- Create the corresponding file in `fabro-config/src/layers/`: paste the deleted Layer content, fix `use` paths, verify `#[derive(fabro_macros::Combine)]` still compiles after the dep is added. +- Rewrite the `Combine` derive macro expansion from `crate::settings::Combine` to `::fabro_config::layers::Combine`. This is a one-line change in `fabro-macros/src/lib.rs`. Derive only works for types inside fabro-config (see Key Technical Decisions — `pub(crate)` trait scopes the derive to the defining crate). +- `fabro-config::layers::Combine` trait definition moves verbatim; visibility is `pub(crate)` to satisfy R3. Every `#[derive(Combine)]` site is inside fabro-config after this unit, so the `pub(crate)` trait path resolves correctly at every call site. +- **Document the `pub(crate)` scoping in the macro's rustdoc** so a future engineer adding a layer type outside fabro-config understands the constraint upfront. +- Merge-specific collection types (`MergeMap`, `ReplaceMap`, `StickyMap`) move to `fabro-config/src/layers/maps.rs` as `pub`. Verified during planning: they do not appear in any resolved Namespace field's public API, only on `*Layer` structs. They must be `pub` (not `pub(crate)`) because `fabro-cli/src/commands/run/overrides.rs` and `fabro-server/src/run_manifest.rs` construct `ReplaceMap` values. +- Integration tests under `fabro-config/tests/` that named `SettingsLayer` directly migrate per the Files list above — either moved into `src/` unit tests or placed behind a `test-support` feature re-export. + +**Execution note:** This unit is one atomic PR. Do not attempt to split into sub-PRs via re-export bridges — the `fabro-types` → `fabro-config` direction forbids it. Land this with the whole workspace's `use` statements updated in one commit. Verify the macro rewrite by deriving `Combine` on a sentinel struct **inside fabro-config** (not an external crate) — the `pub(crate)` trait path is by design unreachable from outside fabro-config, so an external-crate validation would be testing the wrong thing. + +**Patterns to follow:** +- The file structure of `lib/crates/fabro-types/src/settings/` maps 1:1 to the new `lib/crates/fabro-config/src/layers/` structure minus the vocabulary-only files (`duration.rs`, `interp.rs`, `model_ref.rs`, `size.rs`) which stay in `fabro-types`. +- `uv`'s `Combine` pattern (already referenced in 04-23-002) uses absolute paths in its derive expansion — mirror that. + +**Test scenarios:** +- Happy path: `cargo build --workspace` succeeds. +- Happy path: every existing test across every crate passes unchanged (no behavior change in this unit; pure relocation). +- Compile-time: attempting to write `fabro_types::settings::SettingsLayer` in new code fails to compile. +- Compile-time: attempting to write `fabro_config::SettingsLayer` from outside `fabro-config` fails to compile (`pub(crate)`). +- Compile-time: attempting to write `fabro_config::RunLayer` from `fabro-cli` compiles (still `pub`). +- Compile-time: `fabro_types::WorkflowSettings` compiles from any consumer crate (dense bundle relocated). +- Compile-time: `#[derive(fabro_macros::Combine)]` on a struct **inside fabro-config** expands to reference `::fabro_config::layers::Combine` and compiles. +- Compile-time (negative): `#[derive(fabro_macros::Combine)]` on a struct **outside fabro-config** fails to compile with an error citing the private trait — this is the intentional scoping per R10. + +**Verification:** +- `cargo build --workspace`. +- `cargo nextest run --workspace` passes. +- `rg "SettingsLayer" lib/crates/ | grep -v "fabro-config"` returns zero hits across **production and test code** (integration tests under `lib/crates/fabro-config/tests/` that named `SettingsLayer` have been migrated into `src/` unit tests or hidden behind a `test-support` feature — see Files list). +- `rg "use fabro_types::settings::(Settings|Cli|Run|Project|Workflow|Server|Features)Layer"` returns zero hits. +- `rg "use fabro_types::settings::(Combine|MergeMap|ReplaceMap|StickyMap|SpliceArray)"` returns zero hits. +- `rg "fabro_config::\{?UserSettings\b|fabro_config::\{?ServerSettings\b|fabro_config::\{?WorkflowSettings\b"` returns zero hits (dense bundles are only imported from fabro-types; no re-exports from fabro-config exist). +- `rg "crate::settings::Combine" lib/crates/fabro-macros/` returns zero hits (derive macro rewritten to absolute path). +- `fabro-types/Cargo.toml` has no `fabro-config` dependency entry. +- `fabro-config/Cargo.toml` has `fabro-macros` as a new dependency. + +--- + +- [ ] **Unit 4.1: Final audit and lockdown** + +**Goal:** Enforce the boundary. Remove stale artifacts. Make drift observable. + +**Requirements:** R3, R5, R8. This unit is the anti-regression gate. + +**Dependencies:** Unit 3.1. + +**Files:** +- Modify: any stale docstrings, `#[allow(...)]` suppressions, or comments referring to removed types or carve-outs. Known: `lib/crates/fabro-cli/src/local_server.rs` top-of-module docstring (already removed in Unit 2.3 — verify). +- Consider moving `lib/crates/fabro-config/src/storage.rs` (Storage / RuntimeDirectory / RunScratch) if it turns out those are runtime filesystem helpers rather than config. Document decision in PR. + +**Approach:** +- Run every grep in the Anti-Regression Checklist below. Each must produce the expected zero hits. +- Sweep for comments referencing deleted type names. +- Verify `fabro-types/Cargo.toml` and `fabro-config/Cargo.toml` dependency directions. +- Verify `fabro-config`'s public surface matches the target in this plan's Overview — no unexpected public re-exports. + +**Patterns to follow:** +- Lockdown-as-verification is purely observation, not code change. + +**Test scenarios:** +- Test expectation: none — this unit is verification, not behavior. The anti-regression checklist is the test. + +**Verification:** +- Every anti-regression-checklist grep passes. +- Plan status → `completed`. + +## System-Wide Impact + +- **Interaction graph:** + - Workflow engine ↔ `fabro-config`: every call that today resolves on a `SettingsLayer` now reads a dense field. + - CLI ↔ server: wire contract unchanged (TOML strings in manifest). Internal shapes on each side are dense. + - Event stream: `Event::RunCreated.settings` shape changes (sparse → dense). No other event variant is affected. + - `/api/v1/runs/{id}/settings` endpoint: response shape changes (sparse → dense). OpenAPI schema renamed. TypeScript client regenerates. Web UI updates. + - Cross-crate dependency graph: `fabro-config` gains compile-time presence in any crate that previously imported `*Layer` types from `fabro-types`. Primarily `fabro-cli`. Conversely, crates that previously imported `UserSettings`/`ServerSettings`/`WorkflowSettings` from `fabro-config::context` now import from `fabro-types` — they may be able to drop `fabro-config` if they only consumed dense types. Verify dependency direction on each crate Cargo.toml. + +- **Error propagation:** `ResolveErrors` newtype replaces the `Vec` + `render_resolve_errors` pattern. All consumers of resolver errors pass through `Display` instead of the free fn. Format identical (`"; "`-joined). + +- **State lifecycle risks:** + - **Event replay:** Events persisted before this plan lands (in development / test environments) have sparse-layer JSON in `RunCreated.settings`. Dense deserialization will fail on them. Greenfield, so dev-only concern. Document in PR that test databases should be wiped on upgrade. If a live dev environment needs to survive the upgrade, a one-shot schema-translation script could be written, but it's not scoped into this plan. + - **AppState hot-reload:** `replace_server_settings` atomicity preserved (it was already behind an `Arc` swap after 04-22-001). + +- **API surface parity:** + - **External wire surfaces:** + - `/api/v1/settings` endpoint: returns `ServerSettings` today (post-04-22-001). Unchanged. + - `/api/v1/runs/{id}/settings` endpoint: **response shape changes** (old sparse run-settings schema → dense `RunSettings`/`WorkflowSettings`). Schema renamed. TypeScript client and web UI update in lockstep per Unit 2.5. + - Manifest format: unchanged (still TOML strings). + - `Event::RunCreated.settings` JSON: dense shape (accepted breaking change on internal wire contract). + - **CLI flags:** no changes. + - **Environment variables:** no changes. + - **CI config:** no changes. + +- **Integration coverage:** The `prepare_manifest_prefers_bundled_settings_without_duplication` test at `lib/crates/fabro-server/src/run_manifest.rs:990` is the only extant test specifically exercising the sparse-layer snapshot through `prepared.settings`. Updated to assert against dense fields and to drop the obsolete `server.integrations.github.app_id` assertion. Full integration coverage comes from the per-unit test scenarios above. + +- **Unchanged invariants:** + - TOML schema / shape on disk. + - Resolved namespace field sets (runs still see `{ project, workflow, run }`; Server still sees `{ server, features }`; User still sees `{ cli, features }`). + - `WorkflowSettings` field layout from 04-23-001-collapse. + - `Combine` trait semantics and derive macro from 04-23-002 (only the macro expansion path changes; behavior is identical). + - `CommandContext` abstraction boundary from 04-23-001-command-context (field types change, abstraction role doesn't). + - **Server operator supplies defaults for `run.*` fields; client layers override them.** Precedence unchanged vs. today: `args > workflow > project > user > server.run > defaults`. The refactor only changes *how* the merge is expressed (via `Combine` throughout), not *what* overrides what. + - Client CLI arguments continue to override everything else for the current invocation. + +## Risks & Dependencies + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Dense `Event::RunCreated` JSON shape breaks a dev environment's replay | Low | Low | Greenfield, no production. Document in Unit 2.2 PR that test databases should be wiped. | +| `resolve_*_from_file` caller count (~80–108 across 9 crates) makes Unit 1.2 too large for one commit | Medium | Medium | Unit 1.2's execution note allows splitting by namespace. Total caller migration is distributed across Units 1.2 and 2.x — the constraint is that no public `resolve_*_from_file` free fn survives at end of plan. | +| Big atomic PR in Unit 3.1 conflicts with concurrent work | Medium | Medium | Land during a low-churn window. Communicate intent ahead of time. Mechanical changes are easy to rebase. | +| A consumer still holds a `SettingsLayer` that we missed in the audit | Medium | Low | Unit 4.1 anti-regression checklist is explicitly designed to catch this. Every grep must pass before closing the plan. | +| `apply_storage_dir_override` has 6 call sites across 2 crates that Unit 2.1 must cover | Medium | Low | Unit 2.1 explicitly lists all six sites (user.rs, user_config.rs, command_context.rs ×2, install.rs ×2, serve.rs ×2). Replacement is `ServerSettings::with_storage_override(self, &Path) -> ServerSettings` — a post-resolve inherent method on the dense type defined in fabro-types; see Key Technical Decisions. Call-site shape: `ServerSettingsBuilder::load_from(&path)?.with_storage_override(&dir)`. | +| `fabro-macros::Combine` derive macro's relative path breaks when the trait moves | **High** | **High** | **R10 / Unit 3.1:** the macro is explicitly rewritten to use the absolute path `::fabro_config::layers::Combine`. Verified by a sentinel test deriving `Combine` on a struct **inside fabro-config** (the pub(crate) trait is not reachable from other crates by design). **Not** a background risk — it's planned work. | +| `pub(crate)` on `Combine` trait prevents `#[derive(Combine)]` from any crate outside fabro-config | **High (by design)** | Low | Accepted per R10. Every `*Layer` struct that derives `Combine` moves into fabro-config, so every derive site is inside fabro-config. The constraint is documented in the macro's rustdoc. If a future engineer needs `Combine` outside fabro-config, the fallback is to promote the trait to `pub` — documented in the macro rustdoc as the escape hatch. | +| Moving dense bundle types (`UserSettings`, `ServerSettings`, `WorkflowSettings`) from `fabro-config::context` to `fabro-types` breaks inherent-impl-based constructors (`UserSettings::from_layer`, etc.) | **High (by design)** | Medium | Those constructors necessarily move to sibling `UserSettingsBuilder` / `ServerSettingsBuilder` / `WorkflowSettingsBuilder` types in fabro-config. External callers migrate to the public entry points: `UserSettingsBuilder::load_from(&path)?`, `ServerSettingsBuilder::load_from(&path)?`, `ServerSettingsBuilder::from_toml(source)?`, or `WorkflowSettingsBuilder::new()...build()?`. The `from_layer(&SettingsLayer)` form is `pub(crate)` and used only inside fabro-config. Every caller is updated in the **Unit 2.0** atomic sweep (the dense-bundle relocation unit), not Unit 3.1. | +| Additional production code reading `server.*` / `features.*` from materialized settings besides the documented one test | Medium | Medium | **Per review P1-7:** `fabro-server/src/server.rs:1363-1370` has `system_features(settings: &SettingsLayer)` and `fabro-cli/tests/it/cmd/runner.rs:304` reads `integrations.github.app_id` from persisted RunSpec settings. Unit 2.4 (or a dedicated step) catalogs all sites that read `server.*` / `features.*` from a materialized layer and migrates them to `ServerSettings` references. Anti-regression grep #5 catches remaining leaks. | +| Snapshot tests and additional `Event::RunCreated` emitters beyond the enumerated ones drift silently | Medium | Low | Unit 2.2's file list enumerates ~10 emitter files found via workspace grep. Pending-snapshots review (`cargo insta pending-snapshots`) catches stale fixtures. No automatic grep for this — relies on insta's own pending-diff workflow. | + +## Anti-Regression Checklist + +**Every item here is observable via grep or inspection. Each must be true when the plan closes. Copy this checklist into the final Unit 4.1 PR description.** + +```bash +# 1. Deleted artifacts +rg "enforce_server_authority|strip_owner_domains|materialize_settings_layer|EffectiveSettingsLayers" lib/crates/ +# Expected: zero hits + +# 2. Deleted thin wrappers +rg "\bparse_settings_layer\b|\bapply_builtin_defaults\b|\bdefaults_layer\b|\brender_resolve_errors\b|\bresolve_storage_root\b" lib/crates/ +# Expected: zero hits + +# 3. Deleted resolve stack +rg "resolve_cli_from_file|resolve_server_from_file|resolve_project_from_file|resolve_features_from_file|resolve_run_from_file|resolve_workflow_from_file" lib/crates/ +# Expected: zero hits + +# 4. SettingsLayer confined to fabro-config +rg "SettingsLayer" lib/crates/ | rg -v "/fabro-config/" +# Expected: zero hits across production AND test code. Integration tests inside +# lib/crates/fabro-config/tests/ compile as external crates and CANNOT name +# pub(crate) SettingsLayer — any surviving reference is a real bug. Current +# integration tests that do name it (e.g., resolve_run.rs) must be migrated +# into #[cfg(test)] mod tests inside src/**, or placed behind a test-support +# feature re-export — see Unit 3.1 Files list. + +# 5. No struct has a sparse settings field outside fabro-config +rg "settings:\s*&?SettingsLayer" lib/crates/ | rg -v "/fabro-config/" +# Expected: zero hits + +# 6. Cross-crate Layer imports all point at fabro-config +rg "use fabro_types::settings::(Settings|Cli|Run|Project|Workflow|Server|Features)Layer" lib/crates/ +# Expected: zero hits + +# 7. Combine and merge mechanism imports all point at fabro-config +rg "use fabro_types::settings::(Combine|MergeMap|ReplaceMap|StickyMap|SpliceArray)" lib/crates/ +# Expected: zero hits + +# 8. WorkflowSettings is constructed via the builder +rg "WorkflowSettings::(builder|from_layer)\b" lib/crates/ +# Expected: zero hits in production code — `builder` does not exist as an inherent +# method (use `WorkflowSettingsBuilder::new()` instead), and `from_layer` is +# pub(crate) on `WorkflowSettingsBuilder` (not on the dense type itself), so no +# `WorkflowSettings::from_layer` call site should survive. + +# 9. The local_server.rs carve-out docstring is gone +rg "only generic CLI lifecycle surface allowed to read" lib/crates/ +# Expected: zero hits + +# 10. Obsolete run_manifest.rs github-app-id assertion is gone +# Expected: grep for the retired fixture app id in lib/crates/fabro-server/ +# returns zero hits + +# 11. RunSpec and RunCreatedProps retyped +rg "pub settings:\s*SettingsLayer" lib/crates/fabro-types/ +# Expected: zero hits (both retype to WorkflowSettings) + +# 12. apply_storage_dir_override migrated +rg "\bapply_storage_dir_override\b" lib/crates/ +# Expected: zero hits (replaced by a ServerSettings-based method or inlined) + +# 13. Combine derive macro uses absolute path +rg "crate::settings::Combine" lib/crates/fabro-macros/ +# Expected: zero hits + +# 14. Dense bundles imported from fabro-types, not fabro-config +rg "fabro_config::(UserSettings|ServerSettings|WorkflowSettings)" lib/crates/ +# Expected: zero hits (should be fabro_types::UserSettings, etc.) + +# 15. Old sparse run-settings schema name removed +# Expected: repo-wide grep for the retired name returns zero hits (renamed to +# RunSettings or similar) + +# 16. Client override of server default preserved (behavioral invariant) +# Manual: the Unit 1.1 test "user overrides server run.sandbox.provider default" passes. +``` + +**Inspection checklist:** + +- [ ] `fabro-types/Cargo.toml` has no `fabro-config` dependency. +- [ ] `fabro-config/Cargo.toml` has `fabro-macros` as a dependency (gained during Unit 3.1 to support `#[derive(Combine)]` on the relocated `*Layer` structs). +- [ ] `lib/crates/fabro-config/src/layers/settings.rs` has `pub(crate) struct SettingsLayer`. +- [ ] `lib/crates/fabro-config/src/layers/combine.rs` has `pub(crate) trait Combine` and the rustdoc documents the in-crate-only derive constraint. +- [ ] Sub-Layer types (`RunLayer`, `CliLayer`, `ProjectLayer`, `WorkflowLayer`, `ServerLayer`, `FeaturesLayer` and their sub-sub-layers) are still `pub` in `fabro-config` re-exports. +- [ ] `WorkflowSettingsBuilder::new()` is the sole public constructor path that merges multiple settings sources into a `WorkflowSettings`. Public setters take file paths / TOML strings / sub-Layer types only — no public setter exposes `SettingsLayer` (would trip `private_bounds`). +- [ ] `WorkflowSettings`, `UserSettings`, `ServerSettings` are defined in `fabro-types` (struct definitions + inherent read-only impls like `combined_labels()`; constructors are sibling Builder types in fabro-config). +- [ ] `WorkflowSettings` derives `Deserialize` (required by `RunSpec: Deserialize` containing it). Same audit for `UserSettings` and `ServerSettings` where their consumers require it. +- [ ] `RunSpec.settings` and `RunCreatedProps.settings` in `fabro-types` are typed as `WorkflowSettings`. +- [ ] `fabro-cli/src/local_server.rs` module-level docstring no longer describes the module as a carve-out for `[server.*]` access. +- [ ] The obsolete test fragment in `run_manifest.rs` asserting a github app id via `prepared.settings` no longer exists. +- [ ] `Event::RunCreated.settings` documentation comment names `WorkflowSettings` as the value source. +- [ ] `ResolveErrors` (newtype) has a `Display` impl matching the old `render_resolve_errors` format. +- [ ] `FromStr for SettingsLayer` and `From for SettingsLayer` (six impls) exist, all `pub(crate)`. +- [ ] `fabro-macros::Combine` derive emits an absolute trait path (`::fabro_config::layers::Combine`) and its rustdoc documents the in-crate-only constraint. +- [ ] `/api/v1/runs/{id}/settings` endpoint and its OpenAPI schema use the dense `RunSettings` (or equivalent) name and shape. +- [ ] `lib/crates/fabro-api/tests/workflow_settings_round_trip.rs` exists and passes (type-identity + JSON-parity test for the `WorkflowSettings` ↔ `RunSettings` `with_replacement`). +- [ ] `apps/fabro-web/app/routes/run-settings.tsx` and the TypeScript client align with the new schema. +- [ ] Unit 1.1's behavioral test confirms that a user's `run.sandbox.provider` overrides a server-supplied default (client-override semantic preserved). +- [ ] Builder's `build()` sequencing: merge all layers (including defaults), **then** zero `server`/`cli`/`features`, **then** resolve. Confirmed via code read during review. +- [ ] `fabro-config/tests/*.rs` either has no hit for `SettingsLayer` (tests moved into src/ or use a test-support feature facade) OR a dedicated `test-support` feature exists and the tests gate on it. + +## Documentation / Operational Notes + +- **CLAUDE.md:** No changes required. The crate descriptions already say `fabro-config` handles configuration. The Layer-type move is an implementation detail. +- **Dev env reset:** Document in the final PR description that pre-existing dev/test environments with persisted events may fail to replay after Unit 2.2 lands (the `RunCreated.settings` shape shifts from sparse to dense). Recommend wiping local state. +- **OpenAPI-first workflow:** Unit 2.5 follows the repository's standard OpenAPI workflow (CLAUDE.md "API workflow"): edit `docs/api-reference/fabro-api.yaml` first, then `cargo build -p fabro-api` to regenerate Rust types and the reqwest client via progenitor, then `cd lib/packages/fabro-api-client && bun run generate` to regenerate the TypeScript Axios client, then `scripts/refresh-fabro-spa.sh` before committing. +- **No new monitoring, migration, feature flag, or rollout steps** — the refactor is internal-to-Fabro and greenfield. +- **Memory updates (for the implementing engineer):** Update memory `project_fabro_types_vs_config` to record that dense bundle types (`UserSettings`, `ServerSettings`, `WorkflowSettings`) live in fabro-types as vocabulary, with construction (`*SettingsBuilder::from_layer` / `WorkflowSettingsBuilder::new()`) living in fabro-config as the operation. + +## Sources & References + +- **Originating conversation:** the pre-ce:plan chat thread in this session. The summary was captured in the "Fabro Config: Cleanup & Boundary Refactor" handoff document authored immediately before invoking `/ce:plan`. +- **Prior plans this builds on:** + - `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` + - `docs/plans/2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md` + - `docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md` + - `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md` +- **Relevant code anchors:** + - `lib/crates/fabro-config/src/effective_settings.rs` + - `lib/crates/fabro-config/src/context.rs` + - `lib/crates/fabro-config/src/resolve/mod.rs` + - `lib/crates/fabro-server/src/run_manifest.rs` + - `lib/crates/fabro-workflow/src/event.rs` + - `lib/crates/fabro-cli/src/command_context.rs` + - `lib/crates/fabro-cli/src/local_server.rs` + - `lib/crates/fabro-types/src/settings/` +- **Relevant memory entries:** + - `feedback_avoid_oneliner_free_functions` + - `feedback_oop_style_rust` + - `feedback_prefer_robust_types_over_efficiency` + - `project_fabro_types_vs_config` + - `project_fabro_is_single_node` diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 65e6714ca..6a0faa1d0 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -176,7 +176,8 @@ fn main() { "fabro_types::status::RunStatusRecord", &[], ), - ("ServerSettings", "fabro_config::ServerSettings", &[]), + ("WorkflowSettings", "fabro_types::WorkflowSettings", &[]), + ("ServerSettings", "fabro_types::ServerSettings", &[]), ( "ServerNamespace", "fabro_types::settings::ServerNamespace", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 98893ff84..7655ace4d 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -14,7 +14,6 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } pub mod types { - pub use fabro_config::ServerSettings; pub use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, @@ -28,6 +27,7 @@ pub mod types { pub use fabro_types::status::{ BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus, }; + pub use fabro_types::{ServerSettings, WorkflowSettings}; pub use crate::generated::types::*; } diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/crates/fabro-api/tests/server_settings_round_trip.rs index 487c37f77..2181c8884 100644 --- a/lib/crates/fabro-api/tests/server_settings_round_trip.rs +++ b/lib/crates/fabro-api/tests/server_settings_round_trip.rs @@ -4,7 +4,8 @@ use fabro_api::types::{ FeaturesNamespace as ApiFeaturesNamespace, ObjectStoreSettings as ApiObjectStoreSettings, ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings, }; -use fabro_config::{ServerSettings, parse_settings_layer}; +use fabro_config::ServerSettingsBuilder; +use fabro_types::ServerSettings; use fabro_types::settings::server::ObjectStoreSettings; use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; @@ -18,7 +19,7 @@ fn server_settings_family_reuses_domain_types() { #[test] fn server_settings_json_matches_openapi_shape() { - let layer = parse_settings_layer( + let settings = ServerSettingsBuilder::from_toml( r#" _version = 1 @@ -53,8 +54,7 @@ slug = "fabro-dev" session_sandboxes = true "#, ) - .expect("settings fixture should parse"); - let settings = ServerSettings::from_layer(&layer).expect("settings should resolve"); + .expect("settings should resolve"); let json = serde_json::to_value(&settings).expect("server settings should serialize"); assert_eq!(json["server"]["listen"]["type"], "tcp"); diff --git a/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs b/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs new file mode 100644 index 000000000..4c4212c36 --- /dev/null +++ b/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs @@ -0,0 +1,54 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::WorkflowSettings as ApiWorkflowSettings; +use fabro_config::WorkflowSettingsBuilder; +use fabro_types::WorkflowSettings; + +#[test] +fn workflow_settings_family_reuses_domain_types() { + assert_same_type::(); +} + +#[test] +fn workflow_settings_json_matches_openapi_shape() { + let settings = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[project] +directory = "workspace" + +[workflow] +name = "Ship" +graph = "ship.fabro" + +[run] +goal = "Ship it" + +[run.execution] +approval = "auto" +"#, + ) + .expect("settings should resolve"); + + let json = serde_json::to_value(&settings).expect("workflow settings should serialize"); + assert_eq!(json["project"]["directory"], "workspace"); + assert_eq!(json["workflow"]["graph"], "ship.fabro"); + assert_eq!(json["run"]["goal"]["type"], "inline"); + assert_eq!(json["run"]["goal"]["value"], "Ship it"); + assert_eq!(json["run"]["execution"]["approval"], "auto"); + + let round_trip: ApiWorkflowSettings = + serde_json::from_value(json).expect("workflow settings should deserialize"); + assert_eq!(round_trip, settings); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-checkpoint/Cargo.toml b/lib/crates/fabro-checkpoint/Cargo.toml index b443ab206..3734d01fb 100644 --- a/lib/crates/fabro-checkpoint/Cargo.toml +++ b/lib/crates/fabro-checkpoint/Cargo.toml @@ -14,6 +14,7 @@ doctest = false workspace = true [dependencies] +fabro-config = { path = "../fabro-config" } fabro-store = { path = "../fabro-store" } fabro-types = { path = "../fabro-types" } git2.workspace = true diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index d8e364c69..0b1714615 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -1,7 +1,8 @@ use std::fmt::Write; +use fabro_config::GitAuthorLayer; use fabro_types::settings::InterpString; -use fabro_types::settings::run::{GitAuthorLayer, GitAuthorSettings}; +use fabro_types::settings::run::GitAuthorSettings; /// Resolved git author identity for checkpoint commits. #[derive(Debug, Clone, PartialEq)] diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index c358863b3..9998abcf8 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -154,8 +154,7 @@ mod tests { use std::collections::HashMap; use chrono::{TimeZone, Utc}; - use fabro_types::settings::SettingsLayer; - use fabro_types::{Graph, fixtures}; + use fabro_types::{Graph, WorkflowSettings, fixtures}; use super::*; @@ -185,7 +184,7 @@ mod tests { fn test_run_spec(run_id: fabro_types::RunId) -> RunSpec { RunSpec { run_id, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: PathBuf::from("/tmp"), diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 564885e7d..0d73da394 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -3,9 +3,8 @@ use std::path::{Path, PathBuf}; use clap::{Args, Subcommand, ValueEnum}; use fabro_agent::cli::AgentArgs; -use fabro_types::settings::cli::{ - CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer, OutputFormat, OutputVerbosity, -}; +use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer}; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; pub(crate) const LONG_VERSION: &str = concat!( diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index ee215b554..901d5ef5e 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,9 +2,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_config::UserSettings; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_config::CliLayer; +use fabro_types::settings::RunNamespace; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::{ServerSettings, UserSettings}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -12,6 +13,7 @@ use crate::args::{ ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override, }; use crate::server_client::Client; +use crate::user_config::LoadedSettings; use crate::{server_client, user_config}; #[derive(Clone, Debug)] @@ -32,16 +34,25 @@ pub(crate) struct CommandContext { cwd: PathBuf, base_config_path: PathBuf, cli_layer: CliLayer, - machine_settings: SettingsLayer, + storage_dir: PathBuf, + run_settings: std::result::Result, + server_settings: std::result::Result, user_settings: UserSettings, server_mode: ServerMode, server: OnceCell>, } +struct ResolvedCommandSettings { + storage_dir: PathBuf, + run_settings: std::result::Result, + server_settings: std::result::Result, + user_settings: UserSettings, +} + impl CommandContext { pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { - let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; - let printer = printer_from_verbosity(user_settings.cli.output.verbosity); + let resolved_settings = load_merged_settings(cli_layer, &ServerMode::None)?; + let printer = printer_from_verbosity(resolved_settings.user_settings.cli.output.verbosity); let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); @@ -51,8 +62,10 @@ impl CommandContext { cwd, base_config_path, cli_layer: cli_layer.clone(), - machine_settings, - user_settings, + storage_dir: resolved_settings.storage_dir, + run_settings: resolved_settings.run_settings, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode: ServerMode::None, server: OnceCell::new(), }) @@ -87,8 +100,28 @@ impl CommandContext { &self.cwd } - pub(crate) fn machine_settings(&self) -> &SettingsLayer { - &self.machine_settings + #[expect( + dead_code, + reason = "no current consumers since PR commands moved server-side; kept for parity with run_settings/server_settings" + )] + pub(crate) fn storage_dir(&self) -> &Path { + &self.storage_dir + } + + #[expect( + dead_code, + reason = "no current consumers since PR commands moved server-side" + )] + pub(crate) fn server_settings(&self) -> Result<&ServerSettings> { + self.server_settings + .as_ref() + .map_err(|err| anyhow::anyhow!("{err}")) + } + + pub(crate) fn run_settings(&self) -> Result<&RunNamespace> { + self.run_settings + .as_ref() + .map_err(|err| anyhow::anyhow!("{err}")) } pub(crate) fn user_settings(&self) -> &UserSettings { @@ -106,7 +139,8 @@ impl CommandContext { pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); - let machine_settings = self.machine_settings.clone(); + let storage_dir = self.storage_dir.clone(); + let user_settings = self.user_settings.clone(); let client = self .server @@ -122,7 +156,8 @@ impl CommandContext { }; server_client::connect_server_with_settings( &target, - &machine_settings, + &user_settings, + &storage_dir, &base_config_path, ) .await @@ -137,8 +172,7 @@ impl CommandContext { // Always reload settings for the requested derivation mode so the result // depends only on the requested mode, not on whichever derived context // happened to call into this helper. - let (machine_settings, user_settings) = - load_merged_settings(&self.cli_layer, &server_mode)?; + let resolved_settings = load_merged_settings(&self.cli_layer, &server_mode)?; Ok(Self { printer: self.printer, @@ -146,8 +180,10 @@ impl CommandContext { cwd: self.cwd.clone(), base_config_path: self.base_config_path.clone(), cli_layer: self.cli_layer.clone(), - machine_settings, - user_settings, + storage_dir: resolved_settings.storage_dir, + run_settings: resolved_settings.run_settings, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode, server: OnceCell::new(), }) @@ -157,42 +193,43 @@ impl CommandContext { fn load_merged_settings( cli_layer: &CliLayer, server_mode: &ServerMode, -) -> Result<(SettingsLayer, UserSettings)> { - let disk_settings = match server_mode { - ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, +) -> Result { + let loaded_settings = match server_mode { + ServerMode::None | ServerMode::ByTarget { .. } => { + user_config::load_resolved_settings(None, None, Some(cli_layer))? + } ServerMode::ByStorageDir { storage_dir_override, .. - } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, + } => user_config::load_resolved_settings( + None, + storage_dir_override.as_deref(), + Some(cli_layer), + )?, }; - merge_settings_layer(disk_settings, cli_layer) + Ok(resolve_command_settings(loaded_settings)) } -fn merge_settings_layer( - disk_settings: SettingsLayer, - cli_layer: &CliLayer, -) -> Result<(SettingsLayer, UserSettings)> { - let machine_settings = SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() +fn resolve_command_settings(loaded_settings: LoadedSettings) -> ResolvedCommandSettings { + ResolvedCommandSettings { + storage_dir: loaded_settings.storage_dir, + run_settings: loaded_settings.run_settings, + server_settings: loaded_settings.server_settings, + user_settings: loaded_settings.user_settings, } - .combine(disk_settings); - let user_settings = UserSettings::from_layer(&machine_settings)?; - Ok((machine_settings, user_settings)) } #[cfg(test)] mod tests { use std::path::PathBuf; - use fabro_config::parse_settings_layer; - use fabro_config::user::apply_storage_dir_override; - use fabro_types::settings::InterpString; - use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; + use fabro_config::{CliLayer, CliOutputLayer}; + use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; - use super::{CommandContext, ServerMode, merge_settings_layer}; + use super::{CommandContext, ServerMode, resolve_command_settings}; + use crate::user_config; fn cli_layer_with_json_and_verbose() -> CliLayer { CliLayer { @@ -206,17 +243,20 @@ mod tests { fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { let cli_layer = cli_layer_with_json_and_verbose(); - let (machine_settings, user_settings) = - merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) - .expect("settings should merge"); + let resolved_settings = resolve_command_settings( + user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer)) + .expect("settings should resolve"), + ); CommandContext { printer, process_local_json, cwd: PathBuf::from("/tmp/workspace"), base_config_path: PathBuf::from("/tmp/settings.toml"), cli_layer, - machine_settings, - user_settings, + storage_dir: resolved_settings.storage_dir, + run_settings: resolved_settings.run_settings, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode: ServerMode::None, server: OnceCell::new(), } @@ -238,47 +278,98 @@ mod tests { #[test] fn storage_dir_override_only_changes_storage_root_in_merged_settings() { let cli_layer = cli_layer_with_json_and_verbose(); - let base_disk_settings = parse_settings_layer( - r#" + let base_settings = resolve_command_settings( + user_config::load_resolved_settings_from_toml( + r#" _version = 1 [server.storage] root = "/srv/fabro/default" "#, - ) - .expect("settings fixture should parse"); - let override_disk_settings = apply_storage_dir_override( - base_disk_settings.clone(), - Some(std::path::Path::new("/srv/fabro/override")), + None, + Some(&cli_layer), + ) + .expect("base settings should resolve"), + ); + let connection_settings = resolve_command_settings( + user_config::load_resolved_settings_from_toml( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro/default" +"#, + Some(std::path::Path::new("/srv/fabro/override")), + Some(&cli_layer), + ) + .expect("connection settings should resolve"), ); - let (base_settings, base_user_settings) = - merge_settings_layer(base_disk_settings, &cli_layer) - .expect("base settings should merge"); - let (connection_settings, connection_user_settings) = - merge_settings_layer(override_disk_settings, &cli_layer) - .expect("connection settings should merge"); + assert_eq!( + base_settings.user_settings, + connection_settings.user_settings + ); + assert_eq!( + base_settings.user_settings.cli.output.format, + OutputFormat::Json + ); + assert_eq!( + base_settings.storage_dir, + PathBuf::from("/srv/fabro/default") + ); + assert_eq!( + connection_settings.storage_dir, + PathBuf::from("/srv/fabro/override") + ); + assert_eq!(base_settings.run_settings.unwrap().agent.mcps.len(), 0); + assert_eq!( + connection_settings.run_settings.unwrap().agent.mcps.len(), + 0 + ); + assert!(base_settings.server_settings.is_err()); + assert!(connection_settings.server_settings.is_err()); + } - assert_eq!(base_user_settings, connection_user_settings); - assert_eq!(base_user_settings.cli.output.format, OutputFormat::Json); - assert_eq!( - base_settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source), - Some("/srv/fabro/default".to_string()) + #[test] + fn storage_dir_stays_available_when_server_settings_do_not_resolve() { + let resolved = resolve_command_settings( + user_config::load_resolved_settings_from_toml( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro" +"#, + None, + Some(&CliLayer::default()), + ) + .expect("settings should resolve"), ); - assert_eq!( - connection_settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source), - Some("/srv/fabro/override".to_string()) + + assert_eq!(resolved.storage_dir, PathBuf::from("/srv/fabro")); + assert!(resolved.run_settings.is_ok()); + assert!(resolved.server_settings.is_err()); + } + + #[test] + fn run_settings_include_run_agent_mcps() { + let resolved = resolve_command_settings( + user_config::load_resolved_settings_from_toml( + r#" +_version = 1 + +[run.agent.mcps.demo] +type = "stdio" +command = ["demo-mcp"] +"#, + None, + Some(&CliLayer::default()), + ) + .expect("settings should resolve"), ); + + let run_settings = resolved.run_settings.expect("run settings should resolve"); + assert!(run_settings.agent.mcps.contains_key("demo")); } #[test] diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index 926dd23a9..ab927a5cf 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -46,7 +46,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext #[cfg(unix)] { - let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?; let web_url = browser_origin(&target)?; let pkce = fabro_oauth::generate_pkce(); let state = fabro_oauth::generate_state(); diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/crates/fabro-cli/src/commands/auth/logout.rs index f1ad4b806..4ccdfd270 100644 --- a/lib/crates/fabro-cli/src/commands/auth/logout.rs +++ b/lib/crates/fabro-cli/src/commands/auth/logout.rs @@ -34,7 +34,7 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte return Ok(()); } - let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?; let Some(entry) = store.get(&target)? else { fabro_util::printerr!(printer, "Not logged in to {}.", target); return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/crates/fabro-cli/src/commands/auth/status.rs index 808745e7f..fa2f1af56 100644 --- a/lib/crates/fabro-cli/src/commands/auth/status.rs +++ b/lib/crates/fabro-cli/src/commands/auth/status.rs @@ -43,7 +43,7 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res let store = AuthStore::default(); let now = Utc::now(); let rows = if args.server.as_deref().is_some() { - let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, ctx.user_settings())?; filter_rows(&store, &target, now)? } else { all_rows(&store, now)? diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 7dfe3c383..ac1c3ccb1 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -10,7 +10,8 @@ use std::io::Write; use fabro_api::types::ServerSettings; -use fabro_config::UserSettings; +use fabro_config::UserSettingsBuilder; +use fabro_types::UserSettings; use serde::Serialize; use crate::args::SettingsArgs; @@ -25,7 +26,7 @@ struct RenderedConfig { pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { let ctx = base_ctx.with_target(&args.target)?; - let user = fabro_config::UserSettings::resolve()?; + let user = UserSettingsBuilder::load_default()?; let server = ctx .server() .await? diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index c8b218daf..1404767a4 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -12,102 +12,19 @@ use fabro_llm::providers::common::{LineReader, parse_retry_after}; use fabro_llm::types::{ FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts, }; -use fabro_mcp::config::{McpServerSettings, McpTransport}; +use fabro_mcp::config::McpServerSettings; use fabro_types::settings::InterpString; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; -use fabro_types::settings::run::McpEntryLayer; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::stream; use serde::Deserialize; use crate::args::ExecArgs; use crate::command_context::CommandContext; +#[cfg(feature = "sleep_inhibitor")] +use crate::sleep_inhibitor; use crate::{server_client, user_config}; -fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings { - let transport = match entry { - McpEntryLayer::Stdio { - script, - command, - env, - .. - } => { - let command = if let Some(script) = script { - vec!["sh".to_string(), "-c".to_string(), script.as_source()] - } else { - command - .as_ref() - .map(|command| command.iter().map(InterpString::as_source).collect()) - .unwrap_or_default() - }; - McpTransport::Stdio { - command, - env: env - .iter() - .map(|(key, value)| (key.clone(), value.as_source())) - .collect(), - } - } - McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { - url: url.as_source(), - headers: headers - .iter() - .map(|(key, value)| (key.clone(), value.as_source())) - .collect(), - }, - McpEntryLayer::Sandbox { - script, - command, - port, - env, - .. - } => { - let command = if let Some(script) = script { - vec!["sh".to_string(), "-c".to_string(), script.as_source()] - } else { - command - .as_ref() - .map(|command| command.iter().map(InterpString::as_source).collect()) - .unwrap_or_default() - }; - McpTransport::Sandbox { - command, - port: *port, - env: env - .iter() - .map(|(key, value)| (key.clone(), value.as_source())) - .collect(), - } - } - }; - let (startup_timeout_secs, tool_timeout_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(10, |duration| duration.as_std().as_secs()), - tool_timeout.map_or(60, |duration| duration.as_std().as_secs()), - ), - }; - McpServerSettings { - name: name.to_string(), - transport, - startup_timeout_secs, - tool_timeout_secs, - } -} - struct AuthenticatedFabroServerAdapter { client: server_client::Client, base_url: String, @@ -361,9 +278,8 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu use fabro_types::settings::run::AgentPermissions; let cli = &ctx.user_settings().cli; - let raw_settings = user_config::load_settings()?; #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = crate::sleep_inhibitor::guard(cli.exec.prevent_idle_sleep); + let _sleep_guard = sleep_inhibitor::guard(cli.exec.prevent_idle_sleep); let provider_str = cli .exec .model @@ -390,45 +306,12 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu // 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 mcp_servers: Vec = if !cli.exec.agent.mcps.is_empty() { - cli.exec - .agent - .mcps - .values() - .map(|server| McpServerSettings { - name: server.name.clone(), - transport: server.transport.clone(), - startup_timeout_secs: server.startup_timeout_secs, - tool_timeout_secs: server.tool_timeout_secs, - }) - .collect() - } else if let Some(mcps) = raw_settings - .cli - .as_ref() - .and_then(|cli| cli.exec.as_ref()) - .and_then(|exec| exec.agent.as_ref()) - .map(|agent| &agent.mcps) - .filter(|mcps| !mcps.is_empty()) - { - mcps.iter() - .map(|(name, entry)| runtime_mcp_server(name, entry)) - .collect() - } else { - fabro_config::resolve_run_from_file(&raw_settings) - .map(|settings| { - settings - .agent - .mcps - .values() - .map(|server| McpServerSettings { - name: server.name.clone(), - transport: server.transport.clone(), - startup_timeout_secs: server.startup_timeout_secs, - tool_timeout_secs: server.tool_timeout_secs, - }) - .collect() - }) + let mcp_servers: Vec = if cli.exec.agent.mcps.is_empty() { + ctx.run_settings() + .map(|settings| settings.agent.mcps.values().cloned().collect()) .unwrap_or_default() + } else { + cli.exec.agent.mcps.values().cloned().collect() }; if let Some(target) = server_target { tracing::info!(transport = "server", "Agent session starting"); diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index b8416a6c3..4cfab7f13 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -11,9 +11,7 @@ use std::io::Write; use anyhow::{Context, bail}; use fabro_api::types; -use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; use tracing::debug; @@ -37,10 +35,10 @@ pub(crate) async fn run( let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 60c4b016b..5e3ac7915 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -32,7 +32,7 @@ use fabro_install::{ use fabro_model::Provider; use fabro_server::serve; use fabro_store::ArtifactStore; -use fabro_types::settings::SettingsLayer; +use fabro_types::ServerSettings; use fabro_types::settings::server::ServerAuthMethod; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -58,7 +58,7 @@ use crate::shared::provider_auth::{ ApiKeySource, authenticate_provider, authenticate_provider_with_api_key_source, authenticate_provider_with_method, prompt_confirm, prompt_password, provider_display_name, }; -use crate::{local_server, server_client, user_config}; +use crate::{local_server, server_client}; const GITHUB_TOKEN_SECRET_KEY: &str = "GITHUB_TOKEN"; const GITHUB_APP_PRIVATE_KEY_KEY: &str = "GITHUB_APP_PRIVATE_KEY"; @@ -1263,11 +1263,10 @@ fn persist_github_install_changes( } async fn write_artifact_store_metadata( - settings: &SettingsLayer, + settings: &ServerSettings, fabro_version: &str, ) -> Result<()> { - let resolved = fabro_config::ServerSettings::from_layer(settings)?; - let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?; + let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(fabro_version).await?; Ok(()) @@ -1464,16 +1463,11 @@ async fn run_install_github_inner( let existing_config_contents = std::fs::read_to_string(&config_path).context("failed to read existing settings.toml")?; - let parsed_settings = user_config::apply_storage_dir_override( - fabro_config::parse_settings_layer(&existing_config_contents) - .context("failed to parse existing settings.toml")?, - args.storage_dir.as_deref(), - ); - let storage_dir = local_server::storage_dir(&parsed_settings).unwrap_or_else(|_| { - args.storage_dir - .clone_path() - .unwrap_or_else(default_storage_dir) - }); + let storage_dir = args + .storage_dir + .clone_path() + .or_else(|| local_server::storage_dir_from_toml(&existing_config_contents).ok()) + .unwrap_or_else(default_storage_dir); let server_was_running = ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some(); let mut doc: toml::Value = toml::from_str(&existing_config_contents) @@ -1555,11 +1549,9 @@ async fn run_install_github_inner( s.green.apply_to("✔"), bind ); - let methods = fabro_config::parse_settings_layer(&settings_toml) + let methods = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml) .ok() - .and_then(|layer| layer.server) - .and_then(|srv| srv.auth) - .and_then(|auth| auth.methods) + .map(|settings| settings.server.auth.methods) .unwrap_or_default(); let token = methods .contains(&ServerAuthMethod::DevToken) @@ -1614,8 +1606,9 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( let web_url = &args.web_url; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); - let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let storage_dir = local_server::storage_dir(&cli_settings)?; + let local_config = + local_server::LocalServerConfig::load_with_storage_dir(args.storage_dir.as_deref())?; + let storage_dir = local_config.storage_dir().to_path_buf(); let server_was_running = ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some(); let fabro_dir = fabro_util::Home::from_env().root().to_path_buf(); @@ -1777,12 +1770,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( toml::to_string_pretty(&doc)? }; - let install_settings = user_config::apply_storage_dir_override( - fabro_config::parse_settings_layer(&settings_toml) - .context("failed to parse generated settings.toml")?, - args.storage_dir.as_deref(), - ); - fabro_config::ServerSettings::from_layer(&install_settings)?; + let install_server_settings = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)?; // Secrets and auth material { @@ -1793,7 +1781,12 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( s.green.apply_to("✔") ); - let dev_token = if fabro_config::dev_token_auth_enabled(&install_settings) { + let dev_token = if install_server_settings + .server + .auth + .methods + .contains(&ServerAuthMethod::DevToken) + { let token = dev_token::read_or_mint_dev_token_for_install( &fabro_util::Home::from_env().dev_token_path(), )?; @@ -1832,7 +1825,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( server_was_running, ) .await?; - if let Err(err) = write_artifact_store_metadata(&install_settings, FABRO_VERSION).await { + if let Err(err) = write_artifact_store_metadata(&install_server_settings, FABRO_VERSION).await { fabro_util::printerr!( printer, " {} failed to write artifact store metadata: {err}", @@ -1868,13 +1861,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( s.green.apply_to("✔"), bind ); - let methods = install_settings - .server - .as_ref() - .and_then(|srv| srv.auth.as_ref()) - .and_then(|auth| auth.methods.as_ref()) - .map(Vec::as_slice) - .unwrap_or_default(); + let methods = install_server_settings.server.auth.methods.as_slice(); let token = methods .contains(&ServerAuthMethod::DevToken) .then(|| { @@ -2002,16 +1989,10 @@ mod tests { #[test] fn config_toml_roundtrips() { - use fabro_types::settings::SettingsLayer; let toml_str = format_config_toml(); - let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str) - .expect("generated config should parse as v2"); - let methods = cfg - .server - .as_ref() - .and_then(|s| s.auth.as_ref()) - .and_then(|a| a.methods.clone()) - .expect("server.auth.methods should be set"); + let cfg = fabro_config::ServerSettingsBuilder::from_toml(&toml_str) + .expect("generated config should resolve"); + let methods = cfg.server.auth.methods; assert_eq!(methods, vec![ fabro_types::settings::ServerAuthMethod::DevToken ]); @@ -2019,65 +2000,60 @@ mod tests { #[test] fn config_toml_has_auth_strategies() { - use fabro_types::settings::SettingsLayer; let toml_str = format_config_toml(); - let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); - let auth = cfg - .server - .as_ref() - .and_then(|s| s.auth.as_ref()) - .expect("server.auth should be set"); - assert_eq!( - auth.methods, - Some(vec![fabro_types::settings::ServerAuthMethod::DevToken]) - ); + let cfg = fabro_config::ServerSettingsBuilder::from_toml(&toml_str) + .expect("generated config should resolve"); + assert_eq!(cfg.server.auth.methods, vec![ + fabro_types::settings::ServerAuthMethod::DevToken + ]); } #[test] fn config_toml_has_tcp_listen_address() { - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::server::ServerListenLayer; let toml_str = format_config_toml(); - let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); - let listen = cfg - .server - .as_ref() - .and_then(|s| s.listen.as_ref()) - .expect("server.listen should be set"); - match listen { - ServerListenLayer::Tcp { address } => { - assert_eq!( - address - .as_ref() - .map(fabro_types::settings::InterpString::as_source), - Some("127.0.0.1:32276".to_string()) - ); - } - ServerListenLayer::Unix { .. } => panic!("expected tcp listen"), - } + let cfg: toml::Value = toml::from_str(&toml_str).expect("generated config should parse"); + assert_eq!( + cfg.get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("listen")) + .and_then(toml::Value::as_table) + .and_then(|listen| listen.get("type")) + .and_then(toml::Value::as_str), + Some("tcp") + ); + assert_eq!( + cfg.get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("listen")) + .and_then(toml::Value::as_table) + .and_then(|listen| listen.get("address")) + .and_then(toml::Value::as_str), + Some("127.0.0.1:32276") + ); } #[test] fn config_toml_has_cli_target_matching_listen_address() { - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::cli::CliTargetLayer; let toml_str = format_config_toml(); - let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); - let target = cfg - .cli - .as_ref() - .and_then(|c| c.target.as_ref()) - .expect("cli.target should be set"); - match target { - CliTargetLayer::Http { url } => { - assert_eq!( - url.as_ref() - .map(fabro_types::settings::InterpString::as_source), - Some("http://127.0.0.1:32276".to_string()) - ); - } - CliTargetLayer::Unix { .. } => panic!("expected http target"), - } + let cfg: toml::Value = toml::from_str(&toml_str).expect("generated config should parse"); + assert_eq!( + cfg.get("cli") + .and_then(toml::Value::as_table) + .and_then(|cli| cli.get("target")) + .and_then(toml::Value::as_table) + .and_then(|target| target.get("type")) + .and_then(toml::Value::as_str), + Some("http") + ); + assert_eq!( + cfg.get("cli") + .and_then(toml::Value::as_table) + .and_then(|cli| cli.get("target")) + .and_then(toml::Value::as_table) + .and_then(|target| target.get("url")) + .and_then(toml::Value::as_str), + Some("http://127.0.0.1:32276") + ); } #[test] @@ -2116,13 +2092,27 @@ name = "custom" ); } - fn parse_install_settings(source: &str) -> SettingsLayer { - fabro_config::parse_settings_layer(source).expect("install settings fixture should parse") + fn auth_methods(source: &str) -> Option> { + toml::from_str::(source) + .expect("install settings fixture should parse") + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("auth")) + .and_then(toml::Value::as_table) + .and_then(|auth| auth.get("methods")) + .and_then(toml::Value::as_array) + .map(|methods| { + methods + .iter() + .filter_map(toml::Value::as_str) + .map(str::to_string) + .collect() + }) } #[test] fn dev_token_auth_enabled_when_methods_include_dev_token() { - let settings = parse_install_settings( + let methods = auth_methods( r#" _version = 1 @@ -2130,12 +2120,12 @@ _version = 1 methods = ["dev-token"] "#, ); - assert!(fabro_config::dev_token_auth_enabled(&settings)); + assert_eq!(methods, Some(vec!["dev-token".to_string()])); } #[test] fn dev_token_auth_enabled_when_mixed_with_github() { - let settings = parse_install_settings( + let methods = auth_methods( r#" _version = 1 @@ -2143,12 +2133,15 @@ _version = 1 methods = ["dev-token", "github"] "#, ); - assert!(fabro_config::dev_token_auth_enabled(&settings)); + assert_eq!( + methods, + Some(vec!["dev-token".to_string(), "github".to_string()]) + ); } #[test] fn dev_token_auth_enabled_false_for_github_only() { - let settings = parse_install_settings( + let methods = auth_methods( r#" _version = 1 @@ -2156,19 +2149,19 @@ _version = 1 methods = ["github"] "#, ); - assert!(!fabro_config::dev_token_auth_enabled(&settings)); + assert_eq!(methods, Some(vec!["github".to_string()])); } #[test] fn dev_token_auth_enabled_false_when_methods_absent() { - let settings = parse_install_settings( + let methods = auth_methods( " _version = 1 [server.auth] ", ); - assert!(!fabro_config::dev_token_auth_enabled(&settings)); + assert_eq!(methods, None); } #[test] @@ -2947,7 +2940,7 @@ client_id = "client-id" #[tokio::test] async fn write_artifact_store_metadata_creates_marker_in_resolved_store() { let dir = tempfile::tempdir().unwrap(); - let settings = fabro_config::parse_settings_layer(&format!( + let settings = fabro_config::ServerSettingsBuilder::from_toml(&format!( r#" _version = 1 diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index 12088ca04..505671617 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -21,7 +21,7 @@ pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> { } fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> { - let (dot_path, _cfg) = resolve_workflow(&args.workflow)?; + let dot_path = resolve_workflow(&args.workflow)?; let source = read_workflow_file(&dot_path)?; let ast = parse_ast(&source)?; serde_json::to_writer_pretty(&mut out, &ast)?; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 5f98e0179..17b38026d 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,5 +1,4 @@ use anyhow::bail; -use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_util::terminal::Styles; @@ -8,7 +7,7 @@ use crate::command_context::CommandContext; use crate::commands::run::output::{ api_check_report_to_local, api_diagnostics_to_local, print_preflight_workflow_summary, }; -use crate::commands::run::overrides::preflight_args_layer; +use crate::commands::run::overrides::preflight_args_overrides; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args}; use crate::shared::print_json_pretty; @@ -20,14 +19,15 @@ pub(crate) async fn execute( let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; args.verbose = args.verbose || ctx.verbose(); + let cli_args_config = preflight_args_overrides(&args)?; let manifest = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), - args_layer: preflight_args_layer(&args)?, + run_overrides: cli_args_config.run, + cli_overrides: cli_args_config.cli, args: preflight_manifest_args(&args), run_id: None, - user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 712f75b2d..c0d302b77 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -84,10 +84,10 @@ pub(crate) async fn attach_run_with_client( printer: Printer, ) -> Result { let state = client.get_run_state(run_id).await?; - let auto_approve = state.spec.as_ref().is_some_and(|record| { - fabro_config::resolve_run_from_file(&record.settings) - .is_ok_and(|settings| settings.execution.approval == ApprovalMode::Auto) - }); + let auto_approve = state + .spec + .as_ref() + .is_some_and(|record| record.settings.run.execution.approval == ApprovalMode::Auto); let events = client.list_run_events(run_id, None, None).await?; let replay_events = events.clone(); let next_seq = events.last().map_or(1, |event| event.seq.saturating_add(1)); diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index f824e4408..b6692081f 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -4,6 +4,8 @@ use fabro_util::terminal::Styles; use crate::args::RunArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; +#[cfg(feature = "sleep_inhibitor")] +use crate::sleep_inhibitor; pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); @@ -25,7 +27,7 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res } #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep); + let _sleep_guard = sleep_inhibitor::guard(prevent_idle_sleep); #[cfg(not(feature = "sleep_inhibitor"))] let _ = prevent_idle_sleep; diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 9f1e5a105..50972569f 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,10 +1,9 @@ -use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::RunId; use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; -use super::overrides::run_args_layer; +use super::overrides::run_args_overrides; use crate::args::RunArgs; use crate::command_context::CommandContext; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args}; @@ -27,7 +26,7 @@ pub(crate) async fn create_run( .workflow .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; - let cli_args_config = run_args_layer(args)?; + let cli_args_config = run_args_overrides(args)?; let cwd = ctx.cwd().to_path_buf(); let run_id = args .run_id @@ -39,10 +38,10 @@ pub(crate) async fn create_run( let built = build_run_manifest(ManifestBuildInput { workflow: workflow_path.clone(), cwd, - args_layer: cli_args_config, + run_overrides: cli_args_config.run, + cli_overrides: cli_args_config.cli, args: run_manifest_args(args), run_id, - user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index b8451b004..45b10757f 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -4,6 +4,8 @@ use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; +#[cfg(feature = "sleep_inhibitor")] +use crate::sleep_inhibitor; pub(crate) mod attach; pub(crate) mod command; @@ -106,7 +108,7 @@ pub(crate) async fn dispatch( #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { let ctx = base_ctx.with_target(&args.server)?; - crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep) + sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep) }; Box::pin(resume::resume_command(args, styles, base_ctx)).await } diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 7238c05e0..c1b73177b 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -2,17 +2,23 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use fabro_sandbox::SandboxProvider; -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, +use fabro_config::{ + CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer, }; -use fabro_types::settings::{ReplaceMap, SettingsLayer}; +use fabro_sandbox::SandboxProvider; +use fabro_types::settings::cli::OutputVerbosity; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{ApprovalMode, RunMode}; use crate::args::{PreflightArgs, RunArgs}; +#[derive(Clone, Debug, Default)] +pub(crate) struct ManifestSettingsOverrides { + pub(crate) run: Option, + pub(crate) cli: Option, +} + fn sparse_flag(value: bool) -> Option { value.then_some(true) } @@ -116,7 +122,7 @@ fn current_dir_or_dot() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } -pub(crate) fn run_args_layer(args: &RunArgs) -> Result { +pub(crate) fn run_args_overrides(args: &RunArgs) -> Result { let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); let sandbox = sandbox_layer( args.sandbox.map(Into::into), @@ -140,14 +146,13 @@ pub(crate) fn run_args_layer(args: &RunArgs) -> Result { ..RunLayer::default() }; - Ok(SettingsLayer { + Ok(ManifestSettingsOverrides { run: Some(run), cli: cli_layer_for_verbose(args.verbose), - ..SettingsLayer::default() }) } -pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result { +pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result { 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()), @@ -164,10 +169,9 @@ pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result RunEvent { } fn maybe_build_github_credentials( - settings: &SettingsLayer, + settings: &WorkflowSettings, vault: Option<&fabro_vault::Vault>, ) -> Result> { - let resolved_run = fabro_config::resolve_run_from_file(settings).ok(); - let resolved_server = fabro_config::resolve_server_from_file(settings).ok(); - let required_github_credentials = resolved_run.as_ref().is_some_and(|settings| { - settings.execution.mode != RunMode::DryRun && settings.sandbox.provider == "daytona" - }) || resolved_server - .as_ref() - .is_some_and(|settings| !settings.integrations.github.permissions.is_empty()); - let pull_request_enabled = resolved_run.as_ref().is_some_and(|settings| { - settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some() - }); + let resolved_run = &settings.run; + let resolved_server = ServerSettingsBuilder::load_default().ok(); + let required_github_credentials = (resolved_run.execution.mode != RunMode::DryRun + && resolved_run.sandbox.provider == "daytona") + || resolved_server + .as_ref() + .is_some_and(|settings| !settings.server.integrations.github.permissions.is_empty()); + let pull_request_enabled = + resolved_run.execution.mode != RunMode::DryRun && resolved_run.pull_request.is_some(); let strategy = resolved_server .as_ref() - .map(|settings| settings.integrations.github.strategy) + .map(|settings| settings.server.integrations.github.strategy) .unwrap_or_default(); let app_id = resolved_server .as_ref() - .and_then(|settings| settings.integrations.github.app_id.as_ref()) + .and_then(|settings| settings.server.integrations.github.app_id.as_ref()) .map(InterpString::as_source); if required_github_credentials { diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs index d26c82a1b..84f63e614 100644 --- a/lib/crates/fabro-cli/src/commands/server/mod.rs +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -50,12 +50,12 @@ pub(crate) async fn dispatch( return run_install_mode(bootstrap, printer).await; } - let settings = user_config::load_settings_with_config_and_storage_dir( + let local_config = local_server::LocalServerConfig::load( serve_args.config.as_deref(), storage_dir.as_deref(), )?; - let storage_dir = local_server::storage_dir(&settings)?; - let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?; + let storage_dir = local_config.storage_dir().to_path_buf(); + let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?; let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); Box::pin(start::execute( bind_addr, @@ -72,8 +72,9 @@ pub(crate) async fn dispatch( storage_dir, timeout, }) => { - let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?; - let storage_dir = local_server::storage_dir(&settings)?; + let local_config = + local_server::LocalServerConfig::load_with_storage_dir(storage_dir.as_deref())?; + let storage_dir = local_config.storage_dir().to_path_buf(); stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await } ServerCommand::Restart(ServerRestartArgs { @@ -97,13 +98,13 @@ pub(crate) async fn dispatch( return run_install_mode(bootstrap, printer).await; } - let settings = user_config::load_settings_with_config_and_storage_dir( + let local_config = local_server::LocalServerConfig::load( serve_args.config.as_deref(), storage_dir.as_deref(), )?; - let storage_dir = local_server::storage_dir(&settings)?; + let storage_dir = local_config.storage_dir().to_path_buf(); stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await?; - let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?; + let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?; let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); Box::pin(start::execute( bind_addr, @@ -117,15 +118,16 @@ pub(crate) async fn dispatch( .await } ServerCommand::Status(ServerStatusArgs { storage_dir, json }) => { - let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?; - let storage_dir = local_server::storage_dir(&settings)?; + let local_config = + local_server::LocalServerConfig::load_with_storage_dir(storage_dir.as_deref())?; + let storage_dir = local_config.storage_dir().to_path_buf(); status::execute(&storage_dir, json, printer) } ServerCommand::Serve(ServerServeArgs { storage_dir, serve_args, }) => { - let settings = user_config::load_settings_with_config_and_storage_dir( + let local_config = local_server::LocalServerConfig::load( serve_args.config.as_deref(), storage_dir.as_deref(), )?; @@ -135,8 +137,8 @@ pub(crate) async fn dispatch( .clone() .unwrap_or_else(|| user_config::active_settings_path(None)), ); - let storage_dir = local_server::storage_dir(&settings)?; - let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?; + let storage_dir = local_config.storage_dir().to_path_buf(); + let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?; let _ = printer; let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); Box::pin(foreground::serve_with_daemon_record( diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 6380cf1a9..e479749c2 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result, anyhow, bail}; use fabro_config::RuntimeDirectory; use fabro_config::bind::{Bind, BindRequest}; use fabro_config::daemon::ServerDaemon; -use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config}; +use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path}; use fabro_server::jwt_auth::auth_method_name; use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start}; use fabro_server::{process_env_snapshot, validate_startup}; @@ -148,8 +148,7 @@ async fn ensure_server_running_with_bind( let bind_request = if let Some(bind_request) = bind_request { bind_request } else { - let settings = load_settings_config(Some(config_path))?; - local_server::bind_request(&settings, None)? + local_server::LocalServerConfig::load(Some(config_path), None)?.bind_request(None)? }; match execute_daemon( @@ -216,9 +215,9 @@ fn server_max_concurrent_runs_override() -> Option { } fn configured_auth_methods(config_path: Option<&Path>) -> Vec { - load_settings_config(config_path) + local_server::LocalServerConfig::load(config_path, None) .ok() - .map(|settings| local_server::auth_methods(&settings)) + .map(|settings| settings.auth_methods().to_vec()) .unwrap_or_default() } diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index 63b5fd67d..f29f3b73e 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -57,10 +57,11 @@ pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) -> return Ok(()); } - let storage_dir = user_config::load_settings() + let storage_dir = local_server::LocalServerConfig::load_with_storage_dir(None) .ok() - .and_then(|settings| local_server::storage_dir(&settings).ok()) - .unwrap_or_else(user_config::default_storage_dir); + .map_or_else(user_config::default_storage_dir, |settings| { + settings.storage_dir().to_path_buf() + }); let inventory = build_inventory(&home_root, &storage_dir)?; diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 162a980c7..545398987 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,7 +1,5 @@ use anyhow::bail; -use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -20,10 +18,10 @@ pub(crate) async fn run( let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/crates/fabro-cli/src/commands/version.rs index 8b4304b23..0383ec1d1 100644 --- a/lib/crates/fabro-cli/src/commands/version.rs +++ b/lib/crates/fabro-cli/src/commands/version.rs @@ -18,7 +18,7 @@ pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContex let client = client_info(); let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.target)?; - let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; + let server_target = user_config::resolve_server_target(&args.target, ctx.user_settings())?; let server_address = format_server_target(&server_target); let server_info = match ctx.server().await { Ok(server) => match server.get_system_info().await { diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index 742c3cad5..3a23280e5 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -16,14 +16,14 @@ pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContex let printer = base_ctx.printer(); let cwd = std::env::current_dir()?; - let Some((config_path, config)) = discover_project_config(&cwd)? else { + let Some(config_path) = discover_project_config(&cwd)? else { bail!( "No .fabro/project.toml found in {cwd} or any parent directory", cwd = cwd.display() ); }; - let fabro_root = resolve_fabro_root(&config_path, &config); + let fabro_root = resolve_fabro_root(&config_path); let created = write_workflow_scaffold(args, &fabro_root)?; if base_ctx.json_output() { diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index 96553af73..16846762d 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -19,14 +19,14 @@ pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext) let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; - let Some((config_path, config)) = discover_project_config(&cwd)? else { + let Some(config_path) = discover_project_config(&cwd)? else { bail!( "No .fabro/project.toml found in {cwd} or any parent directory", cwd = cwd.display() ); }; - let fabro_root = resolve_fabro_root(&config_path, &config); + let fabro_root = resolve_fabro_root(&config_path); let project_wf_dir = fabro_root.join("workflows"); let user_wf_dir = Some(fabro_util::Home::from_env().workflows_dir()); diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 72bcfc576..21f6cf427 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -1,48 +1,140 @@ //! Helpers for CLI code that manages the local Fabro server on this host. -//! -//! This module is the only generic CLI lifecycle surface allowed to read -//! `[server.*]` settings. User-facing CLI commands outside same-host server -//! lifecycle should not call into it. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_config::bind::BindRequest; -use fabro_server::serve::resolve_bind_request_from_settings; -use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +use fabro_config::user::default_storage_dir; +use fabro_server::serve::resolve_bind_request_from_server_settings; +use fabro_types::ServerSettings; +use fabro_types::settings::{InterpString, ServerAuthMethod}; -pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { - storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) +use crate::user_config; + +pub(crate) struct LocalServerConfig { + storage_dir: PathBuf, + auth_methods: Vec, + config_log_level: Option, + server_settings: std::result::Result, } -pub(crate) fn storage_dir_with_lookup( - settings: &SettingsLayer, +impl LocalServerConfig { + pub(crate) fn load(config_path: Option<&Path>, storage_dir: Option<&Path>) -> Result { + let settings = user_config::load_resolved_settings(config_path, storage_dir, None)?; + Ok(Self::from_loaded_settings(settings)) + } + + pub(crate) fn load_with_storage_dir(storage_dir: Option<&Path>) -> Result { + let settings = user_config::load_resolved_settings(None, storage_dir, None)?; + Ok(Self::from_loaded_settings(settings)) + } + + fn from_loaded_settings(settings: user_config::LoadedSettings) -> Self { + let server_settings = settings.server_settings; + let auth_methods = server_settings + .as_ref() + .map(|resolved| resolved.server.auth.methods.clone()) + .unwrap_or_default(); + Self { + storage_dir: settings.storage_dir, + auth_methods, + config_log_level: settings.config_log_level, + server_settings, + } + } + + pub(crate) fn storage_dir(&self) -> &Path { + &self.storage_dir + } + + pub(crate) fn auth_methods(&self) -> &[ServerAuthMethod] { + &self.auth_methods + } + + pub(crate) fn config_log_level(&self) -> Option<&str> { + self.config_log_level.as_deref() + } + + pub(crate) fn bind_request(&self, cli_override: Option<&str>) -> Result { + let settings = self + .server_settings + .as_ref() + .map_err(|err| anyhow::anyhow!("{err}"))?; + resolve_bind_request_from_server_settings(settings, cli_override) + } +} + +pub(crate) fn storage_dir_from_toml(source: &str) -> Result { + storage_dir_from_toml_with_lookup(source, &|name| std::env::var(name).ok()) +} + +fn storage_dir_from_toml_with_lookup( + source: &str, lookup: &dyn Fn(&str) -> Option, ) -> Result { - let storage_root = fabro_config::resolve_storage_root(settings); + let document: toml::Value = toml::from_str(source) + .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; + let storage_root = string_at_path(&document, &["server", "storage", "root"]).map_or_else( + || InterpString::parse(&default_storage_dir().to_string_lossy()), + |root| InterpString::parse(&root), + ); let resolved_root = storage_root .resolve(lookup) .map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?; Ok(PathBuf::from(resolved_root.value)) } -pub(crate) fn bind_request( - settings: &SettingsLayer, - cli_override: Option<&str>, -) -> Result { - resolve_bind_request_from_settings(settings, cli_override) +fn string_at_path(document: &toml::Value, path: &[&str]) -> Option { + let mut current = document; + for segment in path { + current = current.get(*segment)?; + } + current.as_str().map(str::to_owned) } -pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec { - fabro_config::ServerSettings::from_layer(settings) - .map(|resolved| resolved.server.auth.methods) - .unwrap_or_default() -} +#[cfg(test)] +mod tests { + use std::path::PathBuf; -pub(crate) fn config_log_level(settings: &SettingsLayer) -> Option { - settings - .server - .as_ref() - .and_then(|server| server.logging.as_ref()) - .and_then(|logging| logging.level.clone()) + use fabro_config::user::default_storage_dir; + + use super::{storage_dir_from_toml, storage_dir_from_toml_with_lookup}; + + #[test] + fn storage_dir_from_toml_reads_explicit_root_without_full_server_resolution() { + let path = storage_dir_from_toml( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro" +"#, + ) + .expect("storage root should resolve"); + + assert_eq!(path, PathBuf::from("/srv/fabro")); + } + + #[test] + fn storage_dir_from_toml_defaults_without_auth_methods() { + let path = storage_dir_from_toml("_version = 1\n").expect("default storage dir"); + + assert_eq!(path, default_storage_dir()); + } + + #[test] + fn storage_dir_from_toml_resolves_env_interpolation() { + let path = storage_dir_from_toml_with_lookup( + r#" +_version = 1 + +[server.storage] +root = "{{ env.FABRO_STORAGE_ROOT }}" +"#, + &|name| (name == "FABRO_STORAGE_ROOT").then_some("/srv/fabro".to_string()), + ) + .expect("storage root should resolve"); + + assert_eq!(path, PathBuf::from("/srv/fabro")); + } } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index d42f59a58..f711abd21 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -442,9 +442,8 @@ async fn prepare_server_bootstrap( storage_dir: Option<&std::path::Path>, foreground: bool, ) -> Result { - let settings = - user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?; - let storage_dir = local_server::storage_dir(&settings)?; + let local_config = local_server::LocalServerConfig::load(config_path, storage_dir)?; + let storage_dir = local_config.storage_dir().to_path_buf(); let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.clone()); let foreground_server_log_bootstrap = if foreground { Some(commands::server::start::prepare_foreground_server_log(&runtime_directory).await?) @@ -456,7 +455,7 @@ async fn prepare_server_bootstrap( sink: logging::InternalLogSink::Server { path: runtime_directory.log_path(), }, - config_log_level: local_server::config_log_level(&settings), + config_log_level: local_config.config_log_level().map(str::to_owned), foreground_server_log_bootstrap, }) } diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 895b628fa..0545bef71 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -8,15 +8,14 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use fabro_api::types; -use fabro_config::load::load_settings_for_workflow; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; -use fabro_config::run::{parse_run_config, resolve_run_goal}; +use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace}; +use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::RunId; -use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal}; -use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal}; +use fabro_types::{RunId, WorkflowSettings}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; @@ -25,12 +24,10 @@ use crate::args::{PreflightArgs, RunArgs}; pub(crate) struct ManifestBuildInput { pub workflow: PathBuf, pub cwd: PathBuf, - pub args_layer: SettingsLayer, + pub run_overrides: Option, + pub cli_overrides: Option, pub args: Option, pub run_id: Option, - /// User-level settings layer. Production callers load via - /// `load_settings_user()`; tests pass `SettingsLayer::default()`. - pub user_layer: SettingsLayer, /// Path to the user settings file (for inclusion in /// `RunManifest.configs`). `None` skips the user config entry. pub user_settings_path: Option, @@ -56,14 +53,43 @@ struct WorkflowScanInput { } pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { - let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?; - let merged_settings = input - .args_layer - .clone() - .combine(workflow_layer) - .combine(input.user_layer); - let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; + if root_resolution.workflow_toml_path.is_none() + && !root_resolution.resolved_workflow_path.is_file() + { + return Err(fabro_config::Error::WorkflowNotFound( + root_resolution.resolved_workflow_path.display().to_string(), + ) + .into()); + } + let workflow_parent = root_resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")); + let project_config = discover_project_config(workflow_parent)?; + let mut workflow_settings_builder = WorkflowSettingsBuilder::new(); + if let Some(run) = input.run_overrides.clone() { + workflow_settings_builder = workflow_settings_builder.run_overrides(run); + } + if let Some(cli) = input.cli_overrides.clone() { + workflow_settings_builder = workflow_settings_builder.cli_overrides(cli); + } + if let Some(path) = root_resolution.workflow_toml_path.as_ref() { + workflow_settings_builder = workflow_settings_builder.workflow_file(path)?; + } + if let Some(path) = project_config.as_ref() { + workflow_settings_builder = workflow_settings_builder.project_file(path)?; + } + if let Some(path) = input + .user_settings_path + .as_ref() + .filter(|path| path.is_file()) + { + workflow_settings_builder = workflow_settings_builder.user_file(path)?; + } + let workflow_settings = workflow_settings_builder + .build() + .map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?; let target_path = root_resolution.dot_path.clone(); let target_logical_path = to_logical_path(&target_path, &input.cwd)?; let target_logical_path_string = logical_path_string(&target_logical_path); @@ -82,12 +108,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result, ) -> Result<()> { - let config_layer = parse_run_config(&config.source)?; - let dockerfile = config_layer - .run + let mut document: toml::Table = config + .source + .parse() + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; + let run = document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))? + .unwrap_or_default(); + let dockerfile = run + .sandbox .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()); @@ -389,24 +419,26 @@ fn collect_bundled_file( } fn resolve_manifest_goal( - args_layer: &SettingsLayer, - settings: &SettingsLayer, + run_overrides: Option<&RunLayer>, + settings: &WorkflowSettings, root_source: &str, root_dot_path: &Path, working_directory: &Path, ) -> Result> { // Precedence 1: CLI args (`--goal` / `--goal-file`). These are already // resolved to absolute paths by `overrides::goal_layer_from_args`. - if let Some(resolved) = resolve_run_goal(args_layer, working_directory) - .context("failed to resolve --goal-file contents")? - { - return Ok(Some(resolved_goal_to_manifest(resolved))); + if let Some(run_overrides) = run_overrides { + if let Some(resolved) = resolve_run_goal_from_layer(run_overrides, 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 `load_settings_path` at the // directory of the config file that declared them. - if let Some(resolved) = resolve_run_goal(settings, working_directory) + if let Some(resolved) = resolve_run_goal_from_namespace(&settings.run, working_directory) .context("failed to resolve run.goal.file contents")? { return Ok(Some(resolved_goal_to_manifest(resolved))); @@ -607,10 +639,10 @@ mod tests { let built = build_run_manifest(ManifestBuildInput { workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); @@ -687,10 +719,10 @@ file = "prompts/goal.md" let built = build_run_manifest(ManifestBuildInput { workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); @@ -740,10 +772,10 @@ file = "prompts/goal.md" let built = build_run_manifest(ManifestBuildInput { workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); @@ -803,10 +835,10 @@ working_dir = "repos/target" let built = build_run_manifest(ManifestBuildInput { workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), cwd: workspace.to_path_buf(), - args_layer: SettingsLayer::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, - user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index fc2731121..d6c2551f2 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -11,14 +11,13 @@ use fabro_client::{ pub(crate) use fabro_client::{Client, RunEventStream}; use fabro_config::bind::Bind; pub(crate) use fabro_types::RunProjection; -use fabro_types::settings::SettingsLayer; +use fabro_types::UserSettings; use fabro_util::dev_token::validate_dev_token_format; use fabro_util::{Home, dev_token}; use tokio::time::sleep; use crate::args::ServerTargetArgs; use crate::commands::server::start; -use crate::local_server; use crate::user_config::{self, cli_http_client_builder}; #[derive(Debug)] @@ -67,14 +66,15 @@ pub(crate) async fn connect_server_target_with_bearer( pub(crate) async fn connect_server_with_settings( args: &ServerTargetArgs, - settings: &SettingsLayer, + settings: &UserSettings, + storage_dir: &Path, base_config_path: &Path, ) -> Result { if let Some(target) = user_config::resolve_nondefault_server_target(args, settings)? { if let Some(path) = target.as_unix_socket_path() { return connect_managed_unix_socket_api_client_bundle( path, - &local_server::storage_dir(settings)?, + storage_dir, base_config_path, ) .await; @@ -82,7 +82,7 @@ pub(crate) async fn connect_server_with_settings( return connect_target_api_client_bundle(&target).await; } - connect_local_api_client_bundle(&local_server::storage_dir(settings)?, base_config_path).await + connect_local_api_client_bundle(storage_dir, base_config_path).await } async fn connect_managed_unix_socket_api_client_bundle( diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs index 88b60cb7e..7105c32c1 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/dummy.rs @@ -3,9 +3,9 @@ use tracing::debug; pub(crate) struct DummySleepInhibitor; impl DummySleepInhibitor { - pub(crate) fn acquire() -> Option { + pub(crate) fn acquire() -> Self { debug!("Sleep inhibitor: using dummy backend (no-op)"); - Some(DummySleepInhibitor) + Self } } diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs index bcf842141..432a3fa77 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/iokit_bindings.rs @@ -4,37 +4,37 @@ reason = "FFI bindings preserve IOKit naming and include symbols referenced only on macOS." )] -use core_foundation::string::CFString; +use core_foundation::string::{CFString, CFStringRef}; // IOKit power management assertion types -pub type IOPMAssertionID = u32; -pub const kIOPMAssertionIDInvalid: IOPMAssertionID = 0; +pub(super) type IOPMAssertionID = u32; +pub(super) const kIOPMAssertionIDInvalid: IOPMAssertionID = 0; // IOReturn type -pub type IOReturn = i32; -pub const kIOReturnSuccess: IOReturn = 0; +pub(super) type IOReturn = i32; +pub(super) const kIOReturnSuccess: IOReturn = 0; #[link(name = "IOKit", kind = "framework")] extern "C" { - pub fn IOPMAssertionCreateWithName( - assertion_type: core_foundation::string::CFStringRef, + pub(super) fn IOPMAssertionCreateWithName( + assertion_type: CFStringRef, assertion_level: u32, - reason_for_activity: core_foundation::string::CFStringRef, + reason_for_activity: CFStringRef, assertion_id: *mut IOPMAssertionID, ) -> IOReturn; - pub fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn; + pub(super) fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn; } // Assertion level -pub const kIOPMAssertionLevelOn: u32 = 255; +pub(super) const kIOPMAssertionLevelOn: u32 = 255; /// Create the CFString for "PreventUserIdleSystemSleep". -pub fn prevent_idle_sleep_type() -> CFString { +pub(super) fn prevent_idle_sleep_type() -> CFString { CFString::new("PreventUserIdleSystemSleep") } /// Create a CFString reason. -pub fn assertion_reason() -> CFString { +pub(super) fn assertion_reason() -> CFString { CFString::new("Fabro workflow running") } diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs index 938bca3fe..aef2ab886 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs @@ -6,7 +6,10 @@ use core_foundation::base::TCFType; use tracing::{debug, warn}; -use super::iokit_bindings::*; +use super::iokit_bindings::{ + IOPMAssertionCreateWithName, IOPMAssertionID, IOPMAssertionRelease, assertion_reason, + kIOPMAssertionIDInvalid, kIOPMAssertionLevelOn, kIOReturnSuccess, prevent_idle_sleep_type, +}; pub(crate) struct MacOSSleepInhibitor { assertion_id: IOPMAssertionID, @@ -23,7 +26,7 @@ impl MacOSSleepInhibitor { assertion_type.as_concrete_TypeRef(), kIOPMAssertionLevelOn, reason.as_concrete_TypeRef(), - &mut assertion_id, + &raw mut assertion_id, ) }; diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs index dc5dc18c4..da3c498e3 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/mod.rs @@ -16,7 +16,7 @@ mod dummy; use tracing::debug; /// RAII guard that prevents idle system sleep while held. -pub struct SleepInhibitorGuard { +pub(crate) struct SleepInhibitorGuard { _inner: InnerGuard, } @@ -39,7 +39,7 @@ enum InnerGuard { /// If `enabled` is `true`, attempts to acquire a platform-specific sleep /// inhibitor. Falls back to a dummy (no-op) backend if the platform backend /// is unavailable. -pub fn guard(enabled: bool) -> Option { +pub(crate) fn guard(enabled: bool) -> Option { if !enabled { debug!("Sleep inhibitor: disabled by configuration"); return None; @@ -63,9 +63,8 @@ pub fn guard(enabled: bool) -> Option { } } - // Fallback to dummy - dummy::DummySleepInhibitor::acquire().map(|inner| SleepInhibitorGuard { - _inner: InnerGuard::Dummy(inner), + Some(SleepInhibitorGuard { + _inner: InnerGuard::Dummy(dummy::DummySleepInhibitor::acquire()), }) } diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index d35ba450a..078eab5c7 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,32 +1,156 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; -pub(crate) use fabro_config::user::*; +pub(crate) use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, default_storage_dir}; +use fabro_config::user::{default_settings_path, default_socket_path}; +use fabro_config::{ + CliLayer, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, +}; use fabro_types::settings::cli::CliTargetSettings; -use fabro_types::settings::{CliNamespace, SettingsLayer}; +use fabro_types::settings::{CliNamespace, InterpString, RunNamespace}; +use fabro_types::{ServerSettings, UserSettings}; use fabro_util::version::FABRO_VERSION; use tracing::debug; use crate::args::ServerTargetArgs; -pub(crate) fn load_settings() -> anyhow::Result { - load_settings_with_config_and_storage_dir(None, None) +pub(crate) struct LoadedSettings { + pub(crate) storage_dir: PathBuf, + pub(crate) config_log_level: Option, + pub(crate) run_settings: std::result::Result, + pub(crate) server_settings: std::result::Result, + pub(crate) user_settings: UserSettings, } -pub(crate) fn load_settings_with_storage_dir( - storage_dir: Option<&Path>, -) -> anyhow::Result { - load_settings_with_config_and_storage_dir(None, storage_dir) -} - -pub(crate) fn load_settings_with_config_and_storage_dir( +pub(crate) fn load_resolved_settings( config_path: Option<&Path>, storage_dir: Option<&Path>, -) -> anyhow::Result { - let layer = load_settings_config(config_path)?; - Ok(apply_storage_dir_override(layer, storage_dir)) + cli_layer: Option<&CliLayer>, +) -> anyhow::Result { + let document = load_settings_document(config_path)?; + let storage_override = storage_dir.map(Path::to_path_buf); + let storage_dir = storage_dir_from_document(&document, storage_dir)?; + let config_log_level = config_log_level_from_document(&document); + let run_settings = load_run_settings(config_path).map_err(|err| err.to_string()); + let server_settings = load_server_settings(config_path) + .map(|settings| match storage_override.as_deref() { + Some(dir) => settings.with_storage_override(dir), + None => settings, + }) + .map_err(|err| err.to_string()); + let user_settings = load_user_settings(config_path, cli_layer)?; + + Ok(LoadedSettings { + storage_dir, + config_log_level, + run_settings, + server_settings, + user_settings, + }) +} + +fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result { + load_settings_document_with_lookup(config_path, |name| std::env::var_os(name)) +} + +#[expect( + clippy::disallowed_methods, + reason = "sync settings load during CLI startup; not on a Tokio path" +)] +fn load_settings_document_with_lookup( + config_path: Option<&Path>, + lookup: impl Fn(&str) -> Option, +) -> anyhow::Result { + let config_path = config_path + .map(Path::to_path_buf) + .or_else(|| lookup(FABRO_CONFIG_ENV).map(PathBuf::from)); + + let path = if let Some(path) = config_path { + path + } else { + let default_path = default_settings_path(); + if !default_path.is_file() { + return Ok(toml::Value::Table(toml::Table::new())); + } + default_path + }; + + let contents = std::fs::read_to_string(&path) + .map_err(|source| fabro_config::Error::read_file(&path, source))?; + let table: toml::Table = toml::from_str(&contents).map_err(|source| { + fabro_config::Error::parse_file( + "Failed to parse settings file", + &path, + ParseError::Toml(source.to_string()), + ) + })?; + Ok(toml::Value::Table(table)) +} + +fn load_run_settings(config_path: Option<&Path>) -> anyhow::Result { + Ok(match config_path { + Some(path) => RunSettingsBuilder::load_from(path)?, + None => RunSettingsBuilder::load_default()?, + }) +} + +fn load_server_settings(config_path: Option<&Path>) -> anyhow::Result { + Ok(match config_path { + Some(path) => ServerSettingsBuilder::load_from(path)?, + None => ServerSettingsBuilder::load_default()?, + }) +} + +fn load_user_settings( + config_path: Option<&Path>, + cli_layer: Option<&CliLayer>, +) -> anyhow::Result { + Ok(match (config_path, cli_layer) { + (Some(path), Some(cli_layer)) => { + UserSettingsBuilder::load_from_with_cli_overrides(path, cli_layer)? + } + (Some(path), None) => UserSettingsBuilder::load_from(path)?, + (None, Some(cli_layer)) => UserSettingsBuilder::load_default_with_cli_overrides(cli_layer)?, + (None, None) => UserSettingsBuilder::load_default()?, + }) +} + +fn config_log_level_from_document(document: &toml::Value) -> Option { + string_at_path(document, &["server", "logging", "level"]) +} + +fn storage_dir_from_document( + document: &toml::Value, + storage_dir: Option<&Path>, +) -> anyhow::Result { + storage_dir_from_document_with_lookup(document, storage_dir, &|name| std::env::var(name).ok()) +} + +fn storage_dir_from_document_with_lookup( + document: &toml::Value, + storage_dir: Option<&Path>, + lookup: &dyn Fn(&str) -> Option, +) -> anyhow::Result { + if let Some(dir) = storage_dir { + return Ok(dir.to_path_buf()); + } + + let storage_root = string_at_path(document, &["server", "storage", "root"]).map_or_else( + || InterpString::parse(&default_storage_dir().to_string_lossy()), + |root| InterpString::parse(&root), + ); + let resolved_root = storage_root.resolve(lookup)?; + Ok(PathBuf::from(resolved_root.value)) +} + +fn string_at_path(document: &toml::Value, path: &[&str]) -> Option { + let mut current = document; + for segment in path { + current = current.get(*segment)?; + } + current.as_str().map(str::to_owned) } /// Pull the resolved CLI target configuration out of `[cli.target]`. @@ -39,9 +163,8 @@ fn cli_target_from_settings(settings: &CliNamespace) -> Option { } } -fn configured_server_target(settings: &SettingsLayer) -> Result> { - let user_settings = fabro_config::UserSettings::from_layer(settings)?; - let Some(value) = cli_target_from_settings(&user_settings.cli) else { +fn configured_server_target(settings: &UserSettings) -> Result> { + let Some(value) = cli_target_from_settings(&settings.cli) else { return Ok(None); }; parse_server_target(&value).map(Some) @@ -61,14 +184,14 @@ fn explicit_server_target(args: &ServerTargetArgs) -> Result Result> { Ok(explicit_server_target(args)?.or(configured_server_target(settings)?)) } pub(crate) fn resolve_server_target( args: &ServerTargetArgs, - settings: &SettingsLayer, + settings: &UserSettings, ) -> Result { Ok(resolve_nondefault_server_target(args, settings)?.unwrap_or_else(default_server_target)) } @@ -83,16 +206,48 @@ pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder { fabro_http::HttpClientBuilder::new().user_agent(format!("fabro-cli/{FABRO_VERSION}")) } +#[cfg(test)] +pub(crate) fn load_resolved_settings_from_toml( + source: &str, + storage_dir: Option<&Path>, + cli_layer: Option<&CliLayer>, +) -> anyhow::Result { + let document: toml::Value = toml::from_str(source) + .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; + let storage_override = storage_dir.map(Path::to_path_buf); + let storage_dir = storage_dir_from_document(&document, storage_dir)?; + let config_log_level = config_log_level_from_document(&document); + let run_settings = RunSettingsBuilder::from_toml(source).map_err(|err| err.to_string()); + let server_settings = ServerSettingsBuilder::from_toml(source) + .map(|settings| match storage_override.as_deref() { + Some(dir) => settings.with_storage_override(dir), + None => settings, + }) + .map_err(|err| err.to_string()); + let user_settings = match cli_layer { + Some(cli_layer) => UserSettingsBuilder::from_toml_with_cli_overrides(source, cli_layer)?, + None => UserSettingsBuilder::from_toml(source)?, + }; + + Ok(LoadedSettings { + storage_dir, + config_log_level, + run_settings, + server_settings, + user_settings, + }) +} + #[cfg(test)] mod tests { use std::path::PathBuf; - use fabro_config::parse_settings_layer; + use fabro_config::UserSettingsBuilder; use fabro_config::user::default_storage_dir; + use fabro_types::UserSettings; use super::*; use crate::args::ServerTargetArgs; - use crate::local_server; fn server_target_args(value: Option<&str>) -> ServerTargetArgs { ServerTargetArgs { @@ -100,8 +255,8 @@ mod tests { } } - fn parse_v2(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") + fn parse_user_settings(source: &str) -> UserSettings { + UserSettingsBuilder::from_toml(source).expect("fixture should resolve") } #[test] @@ -132,7 +287,7 @@ mod tests { #[test] fn resolve_server_target_uses_configured_server_target() { - let settings = parse_v2( + let settings = parse_user_settings( r#" _version = 1 @@ -149,7 +304,7 @@ url = "https://config.example.com" #[test] fn resolve_server_target_explicit_target_overrides_config_target() { - let settings = parse_v2( + let settings = parse_user_settings( r#" _version = 1 @@ -170,7 +325,7 @@ url = "https://config.example.com" #[test] fn resolve_server_target_defaults_to_default_unix_socket_target() { - let settings = SettingsLayer::default(); + let settings = UserSettings::default(); assert_eq!( resolve_server_target(&server_target_args(None), &settings).unwrap(), ServerTarget::unix_socket_path(dirs::home_dir().unwrap().join(".fabro/fabro.sock")) @@ -180,7 +335,7 @@ url = "https://config.example.com" #[test] fn explicit_server_target_overrides_config_target() { - let settings = parse_v2( + let settings = parse_user_settings( r#" _version = 1 @@ -210,49 +365,81 @@ url = "https://config.example.com" #[test] fn storage_dir_defaults_without_server_auth_methods() { - let settings = SettingsLayer::default(); + let document = toml::Value::Table(toml::Table::new()); assert_eq!( - local_server::storage_dir(&settings).unwrap(), + storage_dir_from_document(&document, None).unwrap(), default_storage_dir() ); } #[test] fn storage_dir_uses_explicit_server_storage_root() { - let settings = parse_v2( + let document: toml::Value = toml::from_str( r#" _version = 1 [server.storage] root = "/srv/fabro" "#, - ); + ) + .expect("fixture should parse"); assert_eq!( - local_server::storage_dir(&settings).unwrap(), + storage_dir_from_document(&document, None).unwrap(), PathBuf::from("/srv/fabro") ); } #[test] fn storage_dir_resolves_env_interpolated_root() { - let settings = parse_v2( + let document: toml::Value = toml::from_str( r#" _version = 1 [server.storage] root = "{{ env.FABRO_STORAGE_ROOT }}" "#, - ); + ) + .expect("fixture should parse"); let temp = tempfile::tempdir().unwrap(); assert_eq!( - local_server::storage_dir_with_lookup(&settings, &|name| { + storage_dir_from_document_with_lookup(&document, None, &|name| { (name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string()) }) .unwrap(), temp.path() ); } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "unit test writes a temporary settings fixture with sync std::fs::write" + )] + fn load_settings_document_uses_fabro_config_env_for_storage_root() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("settings.toml"); + std::fs::write( + &config_path, + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro" +"#, + ) + .unwrap(); + + let document = load_settings_document_with_lookup(None, |_| { + Some(config_path.clone().into_os_string()) + }) + .expect("settings document should load"); + + assert_eq!( + storage_dir_from_document(&document, None).unwrap(), + PathBuf::from("/srv/fabro") + ); + } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 88a6233ed..2ea0ede24 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -570,53 +570,78 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "run_dir": "[RUN_DIR]", "settings": { - "workflow": { - "graph": "workflow.fabro" - }, - "cli": { - "exec": { - "prevent_idle_sleep": false - }, - "output": { - "format": "text", - "verbosity": "normal" - }, - "target": { - "path": "[CLI_SOCKET]", - "type": "unix" - }, - "updates": { - "check": true - } - }, - "features": { - "session_sandboxes": false - }, "project": { - "directory": "." + "description": null, + "directory": ".", + "metadata": {}, + "name": null }, "run": { + "agent": { + "mcps": {}, + "permissions": null + }, + "artifacts": { + "include": [] + }, + "checkpoint": { + "exclude_globs": [] + }, "execution": { "approval": "prompt", "mode": "normal", "retros": false }, - "goal": "Wait for approval", + "git": { + "author": null + }, + "goal": { + "type": "inline", + "value": "Wait for approval" + }, + "hooks": [], + "inputs": {}, + "interviews": { + "discord": null, + "provider": null, + "slack": null, + "teams": null + }, + "metadata": {}, "model": { + "fallbacks": [], "name": "gpt-5.4", "provider": "openai" }, + "notifications": {}, "prepare": { - "timeout": "5m" + "commands": [], + "timeout_ms": 300000 }, + "pull_request": null, "sandbox": { + "daytona": null, "devcontainer": false, + "env": {}, "local": { "worktree_mode": "clean" }, "preserve": false, "provider": "local" - } + }, + "scm": { + "github": null, + "owner": null, + "provider": null, + "repository": null + }, + "working_dir": null + }, + "workflow": { + "description": null, + "graph": "workflow.fabro", + "metadata": {}, + "name": null } }, "workflow_slug": "human-gate", diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 28dc1db55..ce6f49496 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -5,9 +5,7 @@ use std::path::PathBuf; -use fabro_config::parse_settings_layer; use fabro_test::{fabro_snapshot, test_context}; -use fabro_types::settings::SettingsLayer; use httpmock::MockServer; use predicates::prelude::*; @@ -50,9 +48,8 @@ fn server_storage_root(settings: &serde_json::Value) -> &str { .expect("server.storage.root") } -fn server_settings_layer_fixture() -> SettingsLayer { - parse_settings_layer( - r#" +fn server_settings_toml_fixture() -> &'static str { + r#" _version = 1 [server.auth] @@ -68,13 +65,11 @@ provider = "openai" [run.inputs] server_only = "1" shared = "server" -"#, - ) - .expect("server settings fixture should parse") +"# } fn resolved_server_settings_fixture() -> serde_json::Value { - let settings = fabro_config::ServerSettings::from_layer(&server_settings_layer_fixture()) + let settings = fabro_config::ServerSettingsBuilder::from_toml(server_settings_toml_fixture()) .expect("server settings fixture should resolve"); serde_json::to_value(settings).expect("resolved settings payload should serialize") } @@ -357,10 +352,6 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { run_spec["settings"]["run"]["execution"]["approval"].as_str(), Some("auto") ); - assert_eq!( - run_spec["settings"]["server"]["storage"]["root"].as_str(), - Some(storage_dir.to_str().unwrap()) - ); assert_eq!( run_spec["settings"]["run"]["sandbox"]["preserve"].as_bool(), Some(true) @@ -371,8 +362,8 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { ); // v2 R30: run.prepare.steps replaces the whole ordered list across layers. assert_eq!( - run_spec["settings"]["run"]["prepare"]["steps"], - serde_json::json!([{"script": "workflow-setup"}]) + run_spec["settings"]["run"]["prepare"]["commands"], + serde_json::json!(["workflow-setup"]) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index d1d0bb551..e47b50e51 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -6,10 +6,8 @@ use serde_json::json; use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state}; use crate::support::{fabro_json_snapshot, unique_run_id}; -fn resolved_run( - settings: &fabro_types::settings::SettingsLayer, -) -> fabro_types::settings::RunNamespace { - fabro_config::resolve_run_from_file(settings).expect("run settings should resolve") +fn resolved_run(settings: &fabro_types::WorkflowSettings) -> fabro_types::settings::RunNamespace { + settings.run.clone() } fn run_status_response(run_id: &str, status: &str) -> serde_json::Value { @@ -365,7 +363,6 @@ fn create_persists_requested_overrides_into_store() { }); let settings = &run_spec.settings; let resolved_run = resolved_run(settings); - let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings"); let compact = json!({ "workflow_slug": run_spec.workflow_slug, "settings": { @@ -376,7 +373,6 @@ fn create_persists_requested_overrides_into_store() { "dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun, "auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto, "no_retro": !resolved_run.execution.retros, - "verbose": cli_settings.output.verbosity == fabro_types::settings::cli::OutputVerbosity::Verbose, "llm": { "model": resolved_run.model.name.as_ref().map(fabro_types::settings::InterpString::as_source), "provider": resolved_run.model.provider.as_ref().map(fabro_types::settings::InterpString::as_source), @@ -397,7 +393,6 @@ fn create_persists_requested_overrides_into_store() { "dry_run": true, "auto_approve": true, "no_retro": true, - "verbose": true, "llm": { "model": "gpt-5", "provider": "openai" diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index aa4aced16..111866e17 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -133,7 +133,10 @@ fn inspect_created_run_shows_run_spec_without_start_or_conclusion() { "kind": "submitted" }, "run_spec": { - "goal": "Run tests and report results", + "goal": { + "type": "inline", + "value": "Run tests and report results" + }, "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", @@ -169,7 +172,10 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { "reason": "completed" }, "run_spec": { - "goal": "Run tests and report results", + "goal": { + "type": "inline", + "value": "Run tests and report results" + }, "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", @@ -238,7 +244,10 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() { "reason": "completed" }, "run_spec": { - "goal": "Run tests and report results", + "goal": { + "type": "inline", + "value": "Run tests and report results" + }, "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", @@ -292,7 +301,10 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { "reason": "completed" }, "run_spec": { - "goal": "Edit a tracked file", + "goal": { + "type": "inline", + "value": "Edit a tracked file" + }, "workflow_name": "Flow", "workflow_slug": "flow", "llm_provider": "openai", diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 5b8fcfa63..d11156040 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -340,7 +340,7 @@ digraph CachedGraph { } #[test] -fn runner_uses_snapshotted_app_id_for_github_credentials() { +fn runner_local_dry_runs_ignore_github_app_configuration() { let context = auth_context(); let run_id = unique_run_id(); let workflow_path = context.temp_dir.join("workflow.fabro"); @@ -354,7 +354,7 @@ _version = 1 methods = [\"dev-token\"] [server.integrations.github] -app_id = \"snapshotted-app-id\" +app_id = \"fixture-app-id\" ", ); context.write_temp( @@ -382,21 +382,6 @@ digraph GitHubApp { .success(); let run_dir = context.find_run_dir(&run_id); - let state = run_state(&run_dir); - let run = state.spec.as_ref().expect("run spec should exist"); - let resolved_server = fabro_config::resolve_server_from_file(&run.settings).unwrap(); - fabro_json_snapshot!( - context, - serde_json::json!({ - "app_id": resolved_server.integrations.github.app_id.map(|value| value.as_source()), - }), - @r#" - { - "app_id": "snapshotted-app-id" - } - "# - ); - context.write_home(".fabro/settings.toml", "_version = 1\n"); let server = server_target(&context.storage_dir); diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs index 8a1fce43d..a63dbe450 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs @@ -17,13 +17,13 @@ use axum::extract::{Request, State as AxumState}; use axum::middleware::{self, Next}; use axum::response::Response as AxumResponse; use chrono::{Duration as ChronoDuration, Utc}; -use fabro_config::{parse_settings_layer, resolve_server_from_file}; +use fabro_config::{RunLayer, ServerSettingsBuilder}; use fabro_server::auth::GithubEndpoints; use fabro_server::ip_allowlist::IpAllowlistConfig; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; use fabro_server::server::{ RouterOptions, build_router_with_options, - create_app_state_with_env_lookup_and_server_secret_env, + create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env, }; use fabro_test::{GitHubAppState, TestContext, apply_test_isolation}; use serde_json::Value; @@ -68,7 +68,7 @@ impl RealAuthHarness { let (api_listener, api_base_url) = bind_listener().await; let settings = auth_settings(&api_base_url, &github_client_id, auth_methods); - let resolved = resolve_server_from_file(&settings).expect("settings should resolve"); + let resolved = settings.server.clone(); let dev_token = dev_token.map(str::to_string); let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name { "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), @@ -90,8 +90,13 @@ impl RealAuthHarness { if let Some(token) = dev_token.clone() { secrets.insert("FABRO_DEV_TOKEN".to_string(), token); } - let state = - create_app_state_with_env_lookup_and_server_secret_env(settings, 5, |_| None, &secrets); + let state = create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + settings, + RunLayer::default(), + 5, + |_| None, + &secrets, + ); let github_base = github_base_url(&twin.base_url); let router = build_router_with_options( state, @@ -336,13 +341,13 @@ fn auth_settings( api_base_url: &str, github_client_id: &str, auth_methods: &[&str], -) -> fabro_types::settings::SettingsLayer { +) -> fabro_types::ServerSettings { let auth_methods = auth_methods .iter() .map(|method| format!("\"{method}\"")) .collect::>() .join(", "); - parse_settings_layer(&format!( + ServerSettingsBuilder::from_toml(&format!( r#" _version = 1 @@ -359,7 +364,7 @@ url = "{api_base_url}" client_id = "{github_client_id}" "# )) - .expect("test settings should parse") + .expect("test settings should resolve") } fn github_base_url(base_url: &str) -> fabro_http::Url { diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index a4ec98f7a..65252066c 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -20,6 +20,7 @@ workspace = true anyhow.workspace = true clap = { workspace = true, optional = true } chrono.workspace = true +fabro-macros = { path = "../fabro-macros" } fabro-proc = { path = "../fabro-proc" } fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs new file mode 100644 index 000000000..ab6b041f5 --- /dev/null +++ b/lib/crates/fabro-config/src/builders.rs @@ -0,0 +1,543 @@ +use std::fmt; +use std::path::Path; + +use fabro_types::settings::{ProjectNamespace, RunNamespace, WorkflowNamespace}; +use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; + +use crate::defaults::DEFAULTS_LAYER; +use crate::load::load_settings_path; +use crate::resolve::{ + ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server, + resolve_workflow, +}; +use crate::user::load_settings_config; +use crate::{CliLayer, Combine, Error, Result, RunLayer, ServerLayer, SettingsLayer, run}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveErrors(pub Vec); + +impl ResolveErrors { + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn iter(&self) -> std::slice::Iter<'_, ResolveError> { + self.0.iter() + } + + #[must_use] + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl<'a> IntoIterator for &'a ResolveErrors { + type Item = &'a ResolveError; + type IntoIter = std::slice::Iter<'a, ResolveError>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl fmt::Display for ResolveErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let rendered = self + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "); + f.write_str(&rendered) + } +} + +impl std::error::Error for ResolveErrors {} + +impl From> for ResolveErrors { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl From for Vec { + fn from(value: ResolveErrors) -> Self { + value.0 + } +} + +pub struct ServerSettingsBuilder; + +impl ServerSettingsBuilder { + pub fn load_default() -> Result { + let layer = load_settings_config(None)?; + Self::from_layer(&layer) + } + + pub fn load_from(path: &Path) -> Result { + let layer = load_settings_path(path)?; + Self::from_layer(&layer) + } + + pub fn from_toml(source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + pub(crate) fn from_layer(layer: &SettingsLayer) -> Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors); + let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); + finish_result( + ServerSettings { server, features }, + "failed to resolve server settings", + errors, + ) + } +} + +pub struct UserSettingsBuilder; + +impl UserSettingsBuilder { + pub fn load_default() -> Result { + let layer = load_settings_config(None)?; + Self::from_layer(&layer) + } + + pub fn load_default_with_cli_overrides(cli: &CliLayer) -> Result { + let layer = load_settings_config(None)?; + Self::from_layer_with_cli_overrides(&layer, cli) + } + + pub fn load_from(path: &Path) -> Result { + let layer = load_settings_path(path)?; + Self::from_layer(&layer) + } + + pub fn load_from_with_cli_overrides(path: &Path, cli: &CliLayer) -> Result { + let layer = load_settings_path(path)?; + Self::from_layer_with_cli_overrides(&layer, cli) + } + + pub fn from_toml(source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + pub fn from_toml_with_cli_overrides(source: &str, cli: &CliLayer) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer_with_cli_overrides(&layer, cli) + } + + pub(crate) fn from_layer(layer: &SettingsLayer) -> Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors); + let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); + finish_result( + UserSettings { cli, features }, + "failed to resolve user settings", + errors, + ) + } + + pub(crate) fn from_layer_with_cli_overrides( + layer: &SettingsLayer, + cli: &CliLayer, + ) -> Result { + Self::from_layer( + &SettingsLayer { + cli: Some(cli.clone()), + ..SettingsLayer::default() + } + .combine(layer.clone()), + ) + } +} + +pub struct RunSettingsBuilder; + +impl RunSettingsBuilder { + pub fn load_default() -> Result { + let layer = load_settings_config(None)?; + Self::from_layer(&layer) + } + + pub fn load_from(path: &Path) -> Result { + let layer = load_settings_path(path)?; + Self::from_layer(&layer) + } + + pub fn from_toml(source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + pub(crate) fn from_layer(layer: &SettingsLayer) -> Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + finish_result(run, "failed to resolve run settings", errors) + } + + pub fn from_run_layer(run: &RunLayer) -> Result { + Self::from_layer(&SettingsLayer { + run: Some(run.clone()), + ..SettingsLayer::default() + }) + } +} + +#[derive(Clone)] +pub struct ServerRuntimeSettings { + pub server_settings: ServerSettings, + pub manifest_run_defaults: RunLayer, + pub manifest_run_settings: std::result::Result, +} + +pub fn load_server_runtime_settings( + path: Option<&Path>, + run_overrides: Option, + server_overrides: Option, +) -> Result { + let layer = match path { + Some(path) => load_settings_path(path)?, + None => load_settings_config(None)?, + }; + resolve_server_runtime_settings(layer, run_overrides, server_overrides) +} + +#[cfg(test)] +pub fn server_runtime_settings_from_toml( + source: &str, + run_overrides: Option, + server_overrides: Option, +) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + resolve_server_runtime_settings(layer, run_overrides, server_overrides) +} + +fn resolve_server_runtime_settings( + mut layer: SettingsLayer, + run_overrides: Option, + server_overrides: Option, +) -> Result { + if let Some(run) = run_overrides { + layer = SettingsLayer { + run: Some(run), + ..SettingsLayer::default() + } + .combine(layer); + } + if let Some(server) = server_overrides { + layer = SettingsLayer { + server: Some(server), + ..SettingsLayer::default() + } + .combine(layer); + } + + let manifest_run_defaults = layer.run.clone().unwrap_or_default(); + Ok(ServerRuntimeSettings { + server_settings: ServerSettingsBuilder::from_layer(&layer)?, + manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults) + .map_err(|err| err.to_string()), + manifest_run_defaults, + }) +} + +#[derive(Clone, Debug, Default)] +pub struct WorkflowSettingsBuilder { + args: SettingsLayer, + workflow: SettingsLayer, + project: SettingsLayer, + user: SettingsLayer, + server: SettingsLayer, +} + +impl WorkflowSettingsBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn from_toml(source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + .map_err(|errors| Error::resolve("failed to resolve workflow settings", errors.into())) + } + + #[must_use] + pub(crate) fn args_layer(mut self, layer: SettingsLayer) -> Self { + self.args = layer.combine(self.args); + self + } + + #[must_use] + pub(crate) fn workflow_layer(mut self, layer: SettingsLayer) -> Self { + self.workflow = layer; + self + } + + #[must_use] + pub fn workflow_run_layer(self, run: RunLayer) -> Self { + self.workflow_layer(SettingsLayer { + run: Some(run), + ..SettingsLayer::default() + }) + } + + pub fn workflow_toml(self, source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Ok(self.workflow_layer(layer)) + } + + pub fn workflow_file(self, path: &Path) -> Result { + Ok(self.workflow_layer(run::load_run_config(path)?)) + } + + #[must_use] + pub(crate) fn project_layer(mut self, layer: SettingsLayer) -> Self { + self.project = layer; + self + } + + pub fn project_toml(self, source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Ok(self.project_layer(layer)) + } + + pub fn project_file(self, path: &Path) -> Result { + Ok(self.project_layer(load_settings_path(path)?)) + } + + #[must_use] + pub(crate) fn user_layer(mut self, layer: SettingsLayer) -> Self { + self.user = layer; + self + } + + pub fn user_toml(self, source: &str) -> Result { + let layer = source + .parse::() + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Ok(self.user_layer(layer)) + } + + pub fn user_file(self, path: &Path) -> Result { + Ok(self.user_layer(load_settings_path(path)?)) + } + + #[must_use] + pub(crate) fn server_layer(mut self, layer: SettingsLayer) -> Self { + self.server = layer; + self + } + + #[must_use] + pub fn server_run_defaults(self, run: RunLayer) -> Self { + self.server_layer(SettingsLayer { + run: Some(run), + ..SettingsLayer::default() + }) + } + + #[must_use] + pub fn run_overrides(self, run: RunLayer) -> Self { + self.args_layer(SettingsLayer { + run: Some(run), + ..SettingsLayer::default() + }) + } + + #[must_use] + pub fn cli_overrides(self, cli: CliLayer) -> Self { + self.args_layer(SettingsLayer { + cli: Some(cli), + ..SettingsLayer::default() + }) + } + + #[must_use] + pub(crate) fn build_layer(self) -> SettingsLayer { + let server_defaults = SettingsLayer { + version: self.server.version, + run: self.server.run, + ..SettingsLayer::default() + }; + let mut layer = self + .args + .combine(self.workflow) + .combine(self.project) + .combine(self.user) + .combine(server_defaults); + layer = layer.combine(DEFAULTS_LAYER.clone()); + layer.server = None; + layer.cli = None; + layer.features = None; + layer + } + + pub fn build(self) -> std::result::Result { + Self::from_layer(&self.build_layer()) + } + + pub(crate) fn from_layer( + layer: &SettingsLayer, + ) -> std::result::Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); + let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); + let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + finish_dense_result( + WorkflowSettings { + project, + workflow, + run, + }, + errors, + ) + } + + pub(crate) fn project_from_layer( + layer: &SettingsLayer, + ) -> std::result::Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); + finish_dense_result(project, errors) + } + + pub(crate) fn workflow_from_layer( + layer: &SettingsLayer, + ) -> std::result::Result { + let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); + let mut errors = Vec::new(); + let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); + finish_dense_result(workflow, errors) + } +} + +fn finish_result(value: T, context: &'static str, errors: Vec) -> Result { + if errors.is_empty() { + Ok(value) + } else { + Err(Error::resolve(context, errors)) + } +} + +fn finish_dense_result( + value: T, + errors: Vec, +) -> std::result::Result { + if errors.is_empty() { + Ok(value) + } else { + Err(errors.into()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use fabro_types::settings::InterpString; + use fabro_types::settings::cli::OutputVerbosity; + use fabro_types::settings::run::{ApprovalMode, RunMode}; + + use super::{RunSettingsBuilder, WorkflowSettingsBuilder}; + use crate::{CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunLayer, RunModelLayer}; + + #[test] + fn run_settings_builder_resolves_run_namespace() { + let settings = RunSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.execution] +mode = "dry_run" + +[run.agent.mcps.demo] +type = "stdio" +command = ["demo-mcp"] +"#, + ) + .expect("run settings should resolve"); + + assert_eq!(settings.execution.mode, RunMode::DryRun); + assert!(settings.agent.mcps.contains_key("demo")); + } + + #[test] + fn workflow_builder_preserves_run_overrides_when_cli_overrides_are_added() { + let settings = WorkflowSettingsBuilder::new() + .run_overrides(RunLayer { + metadata: ReplaceMap::from(HashMap::from([("env".to_string(), "cli".to_string())])), + model: Some(RunModelLayer { + provider: Some(InterpString::parse("openai")), + name: Some(InterpString::parse("gpt-5")), + fallbacks: Vec::new(), + }), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + approval: Some(ApprovalMode::Auto), + retros: Some(false), + }), + ..RunLayer::default() + }) + .cli_overrides(CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }) + .build() + .expect("settings should resolve"); + + assert_eq!( + settings.run.metadata.get("env").map(String::as_str), + Some("cli") + ); + assert_eq!( + settings + .run + .model + .provider + .as_ref() + .map(InterpString::as_source), + Some("openai".to_string()) + ); + assert_eq!( + settings + .run + .model + .name + .as_ref() + .map(InterpString::as_source), + Some("gpt-5".to_string()) + ); + assert_eq!(settings.run.execution.mode, RunMode::DryRun); + assert_eq!(settings.run.execution.approval, ApprovalMode::Auto); + assert!(!settings.run.execution.retros); + } +} diff --git a/lib/crates/fabro-config/src/context.rs b/lib/crates/fabro-config/src/context.rs deleted file mode 100644 index 42d77793f..000000000 --- a/lib/crates/fabro-config/src/context.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::collections::HashMap; - -use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, ProjectNamespace, RunNamespace, ServerNamespace, - SettingsLayer, WorkflowNamespace, -}; -use serde::{Deserialize, Serialize}; - -use crate::user::load_settings_config; -use crate::{ - Error, ResolveError, Result, apply_builtin_defaults, resolve_cli, resolve_features, - resolve_project, resolve_run, resolve_server, resolve_workflow, -}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ServerSettings { - pub server: ServerNamespace, - pub features: FeaturesNamespace, -} - -impl ServerSettings { - pub fn from_layer(layer: &SettingsLayer) -> Result { - let layer = apply_builtin_defaults(layer.clone()); - let mut errors = Vec::new(); - let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors); - let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); - if errors.is_empty() { - Ok(Self { server, features }) - } else { - Err(Error::resolve("failed to resolve server settings", errors)) - } - } - - pub fn resolve() -> Result { - let layer = load_settings_config(None)?; - Self::from_layer(&layer) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct UserSettings { - pub cli: CliNamespace, - pub features: FeaturesNamespace, -} - -impl UserSettings { - pub fn from_layer(layer: &SettingsLayer) -> Result { - let layer = apply_builtin_defaults(layer.clone()); - let mut errors = Vec::new(); - let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors); - let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); - if errors.is_empty() { - Ok(Self { cli, features }) - } else { - Err(Error::resolve("failed to resolve user settings", errors)) - } - } - - pub fn resolve() -> Result { - let layer = load_settings_config(None)?; - Self::from_layer(&layer) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct WorkflowSettings { - pub project: ProjectNamespace, - pub workflow: WorkflowNamespace, - pub run: RunNamespace, -} - -impl WorkflowSettings { - pub fn from_layer(layer: &SettingsLayer) -> std::result::Result> { - let layer = apply_builtin_defaults(layer.clone()); - let mut errors = Vec::new(); - let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); - let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); - let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); - if errors.is_empty() { - Ok(Self { - project, - workflow, - run, - }) - } else { - Err(errors) - } - } - - pub fn combined_labels(&self) -> HashMap { - let mut labels = self.project.metadata.clone(); - labels.extend(self.workflow.metadata.clone()); - labels.extend(self.run.metadata.clone()); - labels - } -} diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs index 0d19c0c22..22358d3c1 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -1,20 +1,9 @@ use std::sync::LazyLock; -use fabro_types::settings::{Combine, SettingsLayer}; +use crate::SettingsLayer; -use crate::parse_settings_layer; - -static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { - parse_settings_layer(include_str!("defaults.toml")) +pub(crate) static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { + include_str!("defaults.toml") + .parse::() .expect("embedded defaults.toml must parse as a valid SettingsLayer") }); - -#[must_use] -pub fn defaults_layer() -> &'static SettingsLayer { - &DEFAULTS_LAYER -} - -#[must_use] -pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { - layer.combine(defaults_layer().clone()) -} diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs deleted file mode 100644 index b1cebe1c0..000000000 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Effective settings resolution: combine layers into one resolved -//! [`SettingsLayer`]. -//! -//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge -//! across all three config files (settings.toml, .fabro/project.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/project.toml` and `workflow.toml` remain schema-valid but -//! inert. - -use fabro_types::settings::run::{RunExecutionLayer, RunLayer}; -use fabro_types::settings::server::ServerLayer; -use fabro_types::settings::{Combine, SettingsLayer}; - -use crate::{Error, Result, apply_builtin_defaults}; - -#[derive(Clone, Debug, Default)] -pub struct EffectiveSettingsLayers { - pub args: SettingsLayer, - pub workflow: SettingsLayer, - pub project: SettingsLayer, - pub user: SettingsLayer, -} - -impl EffectiveSettingsLayers { - #[must_use] - pub fn new( - args: SettingsLayer, - workflow: SettingsLayer, - project: SettingsLayer, - user: SettingsLayer, - ) -> Self { - Self { - args, - workflow, - project, - user, - } - } -} - -/// Materialize layered configuration down to a single effective -/// [`SettingsLayer`]. -/// -/// Precedence, lowest to highest: -/// -/// 1. `server_settings.run` / `server_settings.features` — the server's -/// `~/.fabro/settings.toml` contributes its run-level defaults (for example, -/// a server-wide `run.execution.mode = "dry_run"`). Client layers win when -/// they set the same field. -/// 2. `user` — the manifest's `User` configs. -/// 3. `project` — the manifest's `Project` configs, with `cli`/`server` -/// stripped. -/// 4. `workflow` — the manifest's workflow config, with `cli`/`server` -/// stripped. -/// 5. `args` — process-local CLI overrides. -/// 6. A small subset of `server_settings.server` (storage, scheduler, -/// artifacts, web, api) plus `server_settings.features` are applied -/// authoritatively on top. The remaining server-ops fields (listen, auth, -/// ip_allowlist, slatedb, logging, integrations) stay on the server and do -/// not flow into the run's persisted settings — callers that need them -/// should consult the server's resolved settings directly. -pub fn materialize_settings_layer( - layers: EffectiveSettingsLayers, - server_settings: Option<&SettingsLayer>, -) -> Result { - let EffectiveSettingsLayers { - args, - mut workflow, - mut project, - user, - } = layers; - let server_settings = server_settings.ok_or(Error::MissingServerSettings)?; - - // Owner-specific domains (cli, server) may only come from the local - // ~/.fabro/settings.toml, never from .fabro/project.toml or workflow.toml. - // The user layer keeps its cli/server fields. - strip_owner_domains(&mut workflow); - strip_owner_domains(&mut project); - - // Server's run/features stanzas act as base defaults; client layers win. - // Server's server/cli stanzas are handled authoritatively below. - let mut server_defaults = server_settings.clone(); - server_defaults.cli = None; - server_defaults.server = None; - - let combined = args - .combine(workflow) - .combine(project) - .combine(user) - .combine(server_defaults); - let settings = enforce_server_authority(combined, server_settings); - - Ok(apply_builtin_defaults(settings)) -} - -fn strip_owner_domains(file: &mut SettingsLayer) { - file.cli = None; - file.server = None; -} - -/// Apply server-owned fields on top of a client-combined [`SettingsLayer`]. -/// -/// Only the fields runs genuinely need are copied from the server: -/// `storage`, `scheduler`, `artifacts`, `web`, `api`. Operational config -/// (`listen`, `auth`, `ip_allowlist`, `slatedb`, `logging`, `integrations`) -/// stays on the server — callers that need those fields should read -/// `AppState::server_settings()` rather than re-resolving from run settings. -fn enforce_server_authority(mut settings: SettingsLayer, server: &SettingsLayer) -> SettingsLayer { - 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) - .execution - .get_or_insert_with(RunExecutionLayer::default); - settings -} - -#[cfg(test)] -mod tests { - use fabro_types::settings::cli::OutputFormat; - use fabro_types::settings::run::{ApprovalMode, RunGoalLayer}; - use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer}; - use fabro_types::settings::{InterpString, SettingsLayer}; - - use super::{EffectiveSettingsLayers, materialize_settings_layer}; - use crate::parse::parse_settings_layer; - - fn layer(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("v2 fixture should parse") - } - - #[test] - fn materialize_settings_layer_merges_layers_and_applies_server_authority() { - let settings = materialize_settings_layer( - EffectiveSettingsLayers::new( - SettingsLayer::default(), - SettingsLayer::default(), - layer( - r#" -_version = 1 - -[run.model] -name = "project-model" - -[run.inputs] -project_only = "1" -shared = "project" -"#, - ), - layer( - r#" -_version = 1 - -[server.storage] -root = "/tmp/local-storage" - -[run.model] -provider = "openai" - -[run.inputs] -user_only = "1" -shared = "user" -"#, - ), - ), - Some(&layer( - r#" -_version = 1 - -[server.storage] -root = "/srv/fabro" - -[server.scheduler] -max_concurrent_runs = 7 -"#, - )), - ) - .unwrap(); - - assert_eq!( - settings - .run - .as_ref() - .and_then(|run| run.model.as_ref()) - .and_then(|model| model.name.as_ref()) - .map(InterpString::as_source) - .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 - .as_ref() - .and_then(|run| run.inputs.as_ref()) - .unwrap(); - assert!(inputs.contains_key("project_only")); - assert_eq!( - inputs.get("shared").and_then(|value| value.as_str()), - Some("project") - ); - assert!( - !inputs.contains_key("user_only"), - "project.inputs should replace user.inputs wholesale" - ); - assert_eq!( - settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source) - .as_deref(), - Some("/srv/fabro") - ); - assert_eq!( - settings - .server - .as_ref() - .and_then(|server| server.scheduler.as_ref()) - .and_then(|scheduler| scheduler.max_concurrent_runs), - Some(7) - ); - assert_eq!( - settings - .project - .as_ref() - .and_then(|project| project.directory.as_deref()), - Some(".") - ); - assert_eq!( - settings - .workflow - .as_ref() - .and_then(|workflow| workflow.graph.as_deref()), - Some("workflow.fabro") - ); - assert_eq!( - settings - .run - .as_ref() - .and_then(|run| run.execution.as_ref()) - .and_then(|execution| execution.approval), - Some(ApprovalMode::Prompt) - ); - } - - #[test] - fn materialize_settings_layer_preserves_client_values_with_empty_server_layer() { - let settings = materialize_settings_layer( - EffectiveSettingsLayers::new( - SettingsLayer::default(), - layer( - r#" -_version = 1 - -[run] -goal = "workflow goal" - -[run.model] -name = "workflow-model" -"#, - ), - layer( - r#" -_version = 1 - -[run.model] -name = "project-model" -"#, - ), - layer( - r#" -_version = 1 - -[run.model] -provider = "openai" -"#, - ), - ), - Some(&SettingsLayer::default()), - ) - .unwrap(); - - assert_eq!( - match settings.run.as_ref().and_then(|run| run.goal.as_ref()) { - Some(RunGoalLayer::Inline(value)) => Some(value.as_source()), - _ => None, - } - .as_deref(), - Some("workflow goal") - ); - assert_eq!( - settings - .run - .as_ref() - .and_then(|run| run.model.as_ref()) - .and_then(|model| model.name.as_ref()) - .map(InterpString::as_source) - .as_deref(), - Some("workflow-model") - ); - assert_eq!( - settings - .run - .as_ref() - .and_then(|run| run.model.as_ref()) - .and_then(|model| model.provider.as_ref()) - .map(InterpString::as_source) - .as_deref(), - Some("openai") - ); - } - - #[test] - fn materialize_settings_layer_applies_server_owned_overrides() { - let server_settings = SettingsLayer { - server: Some(ServerLayer { - storage: Some(ServerStorageLayer { - root: Some(InterpString::parse("/srv/fabro")), - }), - scheduler: Some(ServerSchedulerLayer { - max_concurrent_runs: Some(7), - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - }; - - let settings = - materialize_settings_layer(EffectiveSettingsLayers::default(), Some(&server_settings)) - .unwrap(); - - assert_eq!( - settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source) - .as_deref(), - Some("/srv/fabro") - ); - assert_eq!( - settings - .server - .as_ref() - .and_then(|server| server.scheduler.as_ref()) - .and_then(|scheduler| scheduler.max_concurrent_runs), - Some(7) - ); - assert_eq!( - settings - .run - .as_ref() - .and_then(|run| run.sandbox.as_ref()) - .and_then(|sandbox| sandbox.provider.as_deref()), - Some("local") - ); - assert_eq!( - settings - .cli - .as_ref() - .and_then(|cli| cli.output.as_ref()) - .and_then(|output| output.format), - Some(OutputFormat::Text) - ); - } -} diff --git a/lib/crates/fabro-config/src/layers/cli.rs b/lib/crates/fabro-config/src/layers/cli.rs new file mode 100644 index 000000000..d529c0b8a --- /dev/null +++ b/lib/crates/fabro-config/src/layers/cli.rs @@ -0,0 +1,108 @@ +//! Sparse `[cli]` settings layer definitions. + +use fabro_types::settings::InterpString; +use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; +use fabro_types::settings::run::AgentPermissions; +use serde::{Deserialize, Serialize}; + +use super::maps::StickyMap; +use super::run::McpEntryLayer; + +/// A sparse `[cli]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, + }, + Unix { + #[serde(default)] + path: 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, +} + +/// `[cli.exec]` — `fabro exec` defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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 = "StickyMap::is_empty")] + pub mcps: StickyMap, +} + +/// `[cli.output]` — generic CLI output defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, +} + +/// `[cli.updates]` — upgrade check toggle. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs similarity index 88% rename from lib/crates/fabro-types/src/settings/combine.rs rename to lib/crates/fabro-config/src/layers/combine.rs index 985f14e66..90f2dec14 100644 --- a/lib/crates/fabro-types/src/settings/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -1,25 +1,31 @@ use std::collections::HashMap; -use super::cli::{ - CliAuthLayer, CliAuthStrategy, CliLoggingLayer, CliTargetLayer, OutputFormat, OutputVerbosity, +use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; +use fabro_types::settings::run::{ + AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, WorktreeMode, }; -use super::duration::Duration; +use fabro_types::settings::server::{ + GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, +}; +use fabro_types::settings::{Duration, InterpString, Size}; + +use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer}; use super::features::FeaturesLayer; -use super::interp::InterpString; use super::run::{ - AgentPermissions, ApprovalMode, DaytonaNetworkLayer, DaytonaSnapshotLayer, HookAgentMarker, - HookEntry, HookTlsMode, InterviewProviderLayer, LocalSandboxLayer, MergeStrategy, - ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, - RunGoalLayer, RunMode, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, WorktreeMode, + DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, + LocalSandboxLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, + RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, }; use super::server::{ - GithubIntegrationStrategy, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer, - ServerApiLayer, ServerAuthGithubLayer, ServerAuthMethod, ServerListenLayer, ServerLoggingLayer, - WebhookStrategy, + ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer, + ServerListenLayer, ServerLoggingLayer, }; -use super::size::Size; -pub trait Combine { +/// Internal merge trait used by sparse config layers inside `fabro-config`. +/// +/// The `fabro_macros::Combine` derive expands against this trait via an +/// absolute path, so deriving `Combine` only works for types defined here. +pub(crate) trait Combine { /// Combine two values, preferring the values in `self`. #[must_use] fn combine(self, other: Self) -> Self; @@ -137,7 +143,7 @@ impl Combine for RunCheckpointLayer { /// An element of a splice-aware sequence: either a regular value or the /// `...` marker that asks the combiner to expand the fallback list inline. -pub trait SpliceMarker { +trait SpliceMarker { fn is_splice(&self) -> bool; } diff --git a/lib/crates/fabro-config/src/layers/features.rs b/lib/crates/fabro-config/src/layers/features.rs new file mode 100644 index 000000000..c2f33cd45 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/features.rs @@ -0,0 +1,14 @@ +//! Sparse `[features]` settings layer definitions. + +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/maps.rs b/lib/crates/fabro-config/src/layers/maps.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/maps.rs rename to lib/crates/fabro-config/src/layers/maps.rs diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs new file mode 100644 index 000000000..331c6a67c --- /dev/null +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -0,0 +1,37 @@ +mod cli; +mod combine; +mod features; +mod maps; +mod project; +mod run; +mod server; +mod settings; +mod splice_array; +mod workflow; + +pub use cli::{ + CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, + CliOutputLayer, CliTargetLayer, CliUpdatesLayer, +}; +pub(crate) use combine::Combine; +pub use features::FeaturesLayer; +pub use maps::{MergeMap, ReplaceMap, StickyMap}; +pub use project::ProjectLayer; +pub use run::{ + DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, GitAuthorLayer, + HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, + LocalSandboxLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, + NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, + RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPrepareLayer, + RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice, +}; +pub use server::{ + DiscordIntegrationLayer, GithubIntegrationLayer, IntegrationWebhooksLayer, + ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerArtifactsLayer, + ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, + ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, + SlackIntegrationLayer, TeamsIntegrationLayer, +}; +pub(crate) use settings::SettingsLayer; +pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-config/src/layers/project.rs b/lib/crates/fabro-config/src/layers/project.rs new file mode 100644 index 000000000..51d2c7110 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/project.rs @@ -0,0 +1,21 @@ +//! Sparse `[project]` settings layer definitions. + +use serde::{Deserialize, Serialize}; + +use super::maps::ReplaceMap; + +/// A sparse `[project]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 + /// `.` after layering when unspecified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, +} diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs new file mode 100644 index 000000000..93deb953c --- /dev/null +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -0,0 +1,492 @@ +//! Sparse `[run]` settings layer definitions. + +use std::collections::HashMap; + +use fabro_types::settings::run::{ + AgentPermissions, ApprovalMode, DaytonaNetworkLayer, HookEvent, MergeStrategy, RunMode, + WorktreeMode, +}; +use fabro_types::settings::{Duration, InterpString, ModelRef, Size}; +use serde::{Deserialize, Serialize}; + +use super::maps::{MergeMap, ReplaceMap, StickyMap}; +use super::splice_array::SPLICE_MARKER; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, + /// 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 = "MergeMap::is_empty")] + pub notifications: MergeMap, + #[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 }, +} + +/// `[run.model]` — provider-neutral default model selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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(SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for ModelRefOrSplice { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + if raw == 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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, +} + +/// `[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, +} + +/// `[run.sandbox]` — sandbox selection and execution-environment surface. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 = "StickyMap::is_empty")] + pub env: StickyMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona: Option, +} + +#[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(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 = "StickyMap::is_empty")] + pub labels: StickyMap, + #[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 }, +} + +/// `[run.notifications.]` — a keyed notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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(SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for StringOrSplice { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == 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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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 = "StickyMap::is_empty")] + pub mcps: StickyMap, +} + +/// 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, +} + +/// `[run.scm]` — remote SCM host/provider behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, +} + +/// `[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, +} diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/crates/fabro-config/src/layers/server.rs new file mode 100644 index 000000000..8b4d30e02 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/server.rs @@ -0,0 +1,260 @@ +//! Sparse `[server]` settings layer definitions. + +use fabro_types::settings::server::{ + GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, +}; +use fabro_types::settings::{Duration, InterpString}; +use serde::{Deserialize, Serialize}; + +use super::maps::StickyMap; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 ip_allowlist: 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, +} + +/// `[server.listen]` — shared bind transport. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum ServerListenLayer { + Tcp { + #[serde(default)] + address: Option, + }, + Unix { + #[serde(default)] + path: 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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthGithubLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_usernames: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ServerIpAllowlistLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entries: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_proxy_count: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ServerIpAllowlistOverrideLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entries: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_proxy_count: Option, +} + +/// `[server.storage]` — single managed local disk root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ServerStorageLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +/// `[server.artifacts]` — object-store-backed artifact storage. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, +} + +/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk_cache: Option, +} + +#[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}/objects/{domain}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + 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 bucket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + 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, +} + +/// `[server.scheduler]` — server-managed execution policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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 strategy: 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 = "StickyMap::is_empty")] + pub permissions: StickyMap, + #[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[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, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct IntegrationWebhooksLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ip_allowlist: Option, +} diff --git a/lib/crates/fabro-types/src/settings/layer.rs b/lib/crates/fabro-config/src/layers/settings.rs similarity index 60% rename from lib/crates/fabro-types/src/settings/layer.rs rename to lib/crates/fabro-config/src/layers/settings.rs index c3dd77837..4d3401c79 100644 --- a/lib/crates/fabro-types/src/settings/layer.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -5,6 +5,8 @@ //! unset in the source stay `None`/empty and are layered later by //! `fabro-config`. +use std::str::FromStr; + use serde::{Deserialize, Serialize}; use super::cli::CliLayer; @@ -13,10 +15,11 @@ use super::project::ProjectLayer; use super::run::RunLayer; use super::server::ServerLayer; use super::workflow::WorkflowLayer; +use crate::parse::{ParseError, parse_settings}; /// A sparse settings layer before merge/resolve. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -pub struct SettingsLayer { +pub(crate) struct SettingsLayer { #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] pub version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -33,13 +36,75 @@ pub struct SettingsLayer { pub features: Option, } -#[cfg(any(test, feature = "test-support"))] +impl FromStr for SettingsLayer { + type Err = ParseError; + + fn from_str(source: &str) -> Result { + parse_settings(source) + } +} + +impl From for SettingsLayer { + fn from(cli: CliLayer) -> Self { + Self { + cli: Some(cli), + ..Self::default() + } + } +} + +impl From for SettingsLayer { + fn from(features: FeaturesLayer) -> Self { + Self { + features: Some(features), + ..Self::default() + } + } +} + +impl From for SettingsLayer { + fn from(project: ProjectLayer) -> Self { + Self { + project: Some(project), + ..Self::default() + } + } +} + +impl From for SettingsLayer { + fn from(run: RunLayer) -> Self { + Self { + run: Some(run), + ..Self::default() + } + } +} + +impl From for SettingsLayer { + fn from(server: ServerLayer) -> Self { + Self { + server: Some(server), + ..Self::default() + } + } +} + +impl From for SettingsLayer { + fn from(workflow: WorkflowLayer) -> Self { + Self { + workflow: Some(workflow), + ..Self::default() + } + } +} + +#[cfg(test)] impl SettingsLayer { /// A default layer that resolves cleanly: populates `server.auth.methods` /// with `["dev-token"]`. Use anywhere a test needs a starter /// `SettingsLayer` that the strict resolver will accept. #[must_use] - pub fn test_default() -> Self { + pub(crate) fn test_default() -> Self { let mut layer = Self::default(); layer.ensure_test_auth_methods(); layer @@ -48,8 +113,10 @@ impl SettingsLayer { /// If `server.auth.methods` is unset, populate it with `["dev-token"]`. /// Existing methods (set by a fixture) are preserved. Use to make a /// parsed-from-TOML layer resolve cleanly without overriding test intent. - pub fn ensure_test_auth_methods(&mut self) { - use super::server::{ServerAuthLayer, ServerAuthMethod, ServerLayer as ServerLayerTy}; + pub(crate) fn ensure_test_auth_methods(&mut self) { + use fabro_types::settings::ServerAuthMethod; + + use super::server::{ServerAuthLayer, ServerLayer as ServerLayerTy}; if self .server diff --git a/lib/crates/fabro-config/src/layers/splice_array.rs b/lib/crates/fabro-config/src/layers/splice_array.rs new file mode 100644 index 000000000..abcfb24c8 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/splice_array.rs @@ -0,0 +1,3 @@ +//! Shared splice marker literal for splice-capable arrays in raw settings. + +pub(crate) const SPLICE_MARKER: &str = "..."; diff --git a/lib/crates/fabro-config/src/layers/workflow.rs b/lib/crates/fabro-config/src/layers/workflow.rs new file mode 100644 index 000000000..5a20da896 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/workflow.rs @@ -0,0 +1,20 @@ +//! Sparse `[workflow]` settings layer definitions. + +use serde::{Deserialize, Serialize}; + +use super::maps::ReplaceMap; + +/// A sparse `[workflow]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[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 = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index bff64f72f..c02ffa4f7 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,45 +2,59 @@ clippy::disallowed_methods, reason = "sync config loading utilities used at startup; not on a Tokio path" )] -//! Resolved settings entrypoints: [`ServerSettings`] for the running server, -//! [`UserSettings`] for the CLI/user perspective, and [`WorkflowSettings`] for -//! workflow execution. +//! Configuration loading and resolution helpers. extern crate self as fabro_config; -pub mod context; +pub mod builders; mod defaults; +mod layers; pub mod bind; pub mod daemon; -pub mod effective_settings; pub mod envfile; pub mod error; pub mod home; -pub mod load; +mod load; pub mod parse; pub mod project; pub mod resolve; pub mod run; pub mod storage; +#[cfg(test)] +mod tests; pub mod user; use std::path::Path; -pub use context::{ServerSettings, UserSettings, WorkflowSettings}; -pub use defaults::{apply_builtin_defaults, defaults_layer}; +pub use builders::{ + ResolveErrors, RunSettingsBuilder, ServerRuntimeSettings, ServerSettingsBuilder, + UserSettingsBuilder, WorkflowSettingsBuilder, load_server_runtime_settings, +}; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; -pub use load::{ - load_settings_for_workflow, load_settings_path, load_settings_project, load_settings_user, +pub use layers::{ + CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, + CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer, + DaytonaSnapshotLayer, DiscordIntegrationLayer, FeaturesLayer, GitAuthorLayer, + GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, + InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, McpEntryLayer, MergeMap, + ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, + ObjectStoreS3Layer, PrepareStep, ProjectLayer, ReplaceMap, RunAgentLayer, RunArtifactsLayer, + RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, + ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, + ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, + ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, + ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, + TeamsIntegrationLayer, WorkflowLayer, }; -pub use parse::{ParseError, parse_settings_layer}; +pub(crate) use layers::{Combine, SettingsLayer}; +pub use parse::ParseError; pub use resolve::{ - ResolveError, dev_token_auth_enabled, render_resolve_errors, resolve_cli, - resolve_cli_from_file, resolve_features, resolve_features_from_file, resolve_project, - resolve_project_from_file, resolve_run, resolve_run_from_file, resolve_server, - resolve_server_from_file, resolve_storage_root, resolve_workflow, resolve_workflow_from_file, + ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server, + resolve_workflow, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs index 9e7f17c4b..5a2254603 100644 --- a/lib/crates/fabro-config/src/load.rs +++ b/lib/crates/fabro-config/src/load.rs @@ -5,52 +5,20 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::run::RunGoalLayer; -use fabro_types::settings::{Combine, InterpString, SettingsLayer}; +use fabro_types::settings::InterpString; -use crate::parse::parse_settings_layer; -use crate::{Error, Result, project, user}; +use crate::{Error, Result, RunGoalLayer, SettingsLayer}; -pub fn load_settings_path(path: &Path) -> Result { +pub(crate) fn load_settings_path(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?; - let mut layer = parse_settings_layer(&content) + let mut layer = content + .parse::() .map_err(|err| Error::parse_file("Failed to parse settings file", path, err))?; let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); resolve_goal_file_paths(&mut layer, base_dir); Ok(layer) } -pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> Result { - let resolution = project::resolve_workflow_path(path, cwd)?; - if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { - return Err(Error::WorkflowNotFound( - resolution.resolved_workflow_path.display().to_string(), - )); - } - - let workflow_config = resolution.workflow_config.unwrap_or_default(); - let project_config = project::discover_project_config( - resolution - .resolved_workflow_path - .parent() - .unwrap_or_else(|| Path::new(".")), - )? - .map(|(_, config)| config) - .unwrap_or_default(); - - Ok(workflow_config.combine(project_config)) -} - -pub fn load_settings_project(start: &Path) -> Result { - Ok(project::discover_project_config(start)? - .map(|(_, config)| config) - .unwrap_or_default()) -} - -pub fn load_settings_user() -> Result { - user::load_settings_config(None) -} - pub(crate) fn resolve_goal_file_paths(file: &mut SettingsLayer, base_dir: &Path) { let Some(run) = file.run.as_mut() else { return; diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index 219c04939..50d842b31 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -1,6 +1,6 @@ use std::fmt; -use fabro_types::settings::SettingsLayer; +use crate::SettingsLayer; const CURRENT_VERSION: u32 = 1; @@ -58,7 +58,7 @@ impl fmt::Display for VersionError { impl std::error::Error for VersionError {} -pub fn parse_settings_layer(input: &str) -> Result { +pub(crate) fn parse_settings(input: &str) -> Result { let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?; validate_version(&raw).map_err(ParseError::Version)?; @@ -135,19 +135,19 @@ mod tests { #[test] fn parses_empty_file() { - let file = parse_settings_layer("").unwrap(); + let file = "".parse::().unwrap(); assert_eq!(file, SettingsLayer::default()); } #[test] fn parses_minimal_valid_file() { - let file = parse_settings_layer("_version = 1\n").unwrap(); + let file = "_version = 1\n".parse::().unwrap(); assert_eq!(file.version, Some(1)); } #[test] fn rejects_legacy_version_key_with_rename_hint() { - let err = parse_settings_layer("version = 1").unwrap_err(); + let err = "version = 1".parse::().unwrap_err(); assert!(matches!( err, ParseError::Version(VersionError::LegacyVersionKey) @@ -157,13 +157,13 @@ mod tests { #[test] fn rejects_unknown_top_level_key() { - let err = parse_settings_layer("unknown_key = 1").unwrap_err(); + let err = "unknown_key = 1".parse::().unwrap_err(); assert!(matches!(err, ParseError::UnknownTopLevelKey { .. })); } #[test] fn higher_version_rejected_with_upgrade_hint() { - let err = parse_settings_layer("_version = 99").unwrap_err(); + let err = "_version = 99".parse::().unwrap_err(); assert!(err.to_string().contains("Upgrade")); } } diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index d62584669..e34cb9346 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,53 +12,42 @@ use std::fmt::Write; use std::path::{Component, Path, PathBuf}; -use fabro_types::settings::SettingsLayer; +use fabro_types::settings::{InterpString, RunNamespace}; use serde::Serialize; use crate::load::load_settings_path; -use crate::parse::parse_settings_layer; -use crate::{ - Error, Result, resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file, - run, -}; +use crate::{Error, Result, SettingsLayer, WorkflowSettingsBuilder, run}; const CONFIG_FILENAME: &str = ".fabro/project.toml"; #[derive(Clone, Debug)] pub struct WorkflowPathResolution { pub resolved_workflow_path: PathBuf, pub dot_path: PathBuf, - pub workflow_config: Option, pub workflow_toml_path: Option, pub workflow_slug: Option, } -/// Parse a project config from a TOML string. -pub fn parse_project_config(content: &str) -> Result { - parse_settings_layer(content).map_err(|err| Error::parse("Failed to parse project config", err)) -} - /// Load a project config from a file path. /// /// Goes through [`load_settings_path`] so that relative `run.goal.file` /// paths are anchored at the directory of `path` at load time. -pub fn load_project_config(path: &Path) -> Result { +fn load_project_config(path: &Path) -> Result { let config = load_settings_path(path)?; - let root = resolve_project_from_file(&config) - .map_err(|errors| Error::resolve("Failed to resolve project settings", errors))? + let root = WorkflowSettingsBuilder::project_from_layer(&config) + .map_err(|errors| Error::resolve("Failed to resolve project settings", errors.into()))? .directory; tracing::debug!(path = %path.display(), root = %root, "Loaded project config"); Ok(config) } /// Walk ancestor directories from `start` looking for `.fabro/project.toml`. -/// Returns the config file path and parsed config, or `None` if not found. -pub fn discover_project_config(start: &Path) -> Result> { +/// Returns the config file path, or `None` if not found. +pub fn discover_project_config(start: &Path) -> Result> { for ancestor in start.ancestors() { let candidate = ancestor.join(CONFIG_FILENAME); if candidate.is_file() { tracing::debug!(path = %candidate.display(), "Discovered project config"); - let config = load_project_config(&candidate)?; - return Ok(Some((candidate, config))); + return Ok(Some(candidate)); } } Ok(None) @@ -94,14 +83,14 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result { - let workflow = resolve_workflow_from_file(&cfg).map_err(|errors| { - Error::resolve("Failed to resolve workflow settings", errors) - })?; + let workflow = + WorkflowSettingsBuilder::workflow_from_layer(&cfg).map_err(|errors| { + Error::resolve("Failed to resolve workflow settings", errors.into()) + })?; let dot_path = run::resolve_graph_path(&path, &workflow.graph); Ok(WorkflowPathResolution { resolved_workflow_path: path.clone(), dot_path, - workflow_config: Some(cfg), workflow_toml_path: Some(path), workflow_slug, }) @@ -113,22 +102,17 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result PathBuf { - let Some(work_dir) = resolve_run_from_file(settings) - .ok() - .and_then(|settings| settings.working_dir) - .map(|value| value.as_source()) - else { +pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf { + let Some(work_dir) = run.working_dir.as_ref().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 { @@ -161,8 +145,8 @@ fn resolve_workflow_arg_impl( let name = arg.to_string_lossy(); match discover_project_config(start_dir) { - Ok(Some((config_path, config))) => { - let fabro_root = resolve_fabro_root(&config_path, &config); + Ok(Some(config_path)) => { + let fabro_root = resolve_fabro_root(&config_path); let project_candidate = fabro_root .join("workflows") .join(&*name) @@ -344,10 +328,10 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option { } /// Resolve a workflow argument to a DOT path and optional run config. -pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option)> { +pub fn resolve_workflow(arg: &Path) -> Result { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let resolution = resolve_workflow_path(arg, &start)?; - Ok((resolution.dot_path, resolution.workflow_config)) + Ok(resolution.dot_path) } /// Check whether retros are enabled in the project config. @@ -355,11 +339,15 @@ pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option)> pub fn is_retro_enabled() -> bool { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); match discover_project_config(&start) { - Ok(Some((_path, config))) => config - .run - .as_ref() - .and_then(|r| r.execution.as_ref()) - .and_then(|e| e.retros) + Ok(Some(path)) => load_project_config(&path) + .ok() + .and_then(|config| { + config + .run + .as_ref() + .and_then(|r| r.execution.as_ref()) + .and_then(|e| e.retros) + }) .unwrap_or(false), _ => false, } @@ -388,11 +376,12 @@ fn normalize_joined_path(base_dir: &Path, reference: &Path) -> PathBuf { /// Resolve the fabro root directory from a config file path and its config. /// The returned path is the config file's parent directory joined with the /// `project.directory` value (default: `.`). -pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf { +pub fn resolve_fabro_root(config_path: &Path) -> PathBuf { let project_dir = config_path .parent() .expect("config_path should have a parent directory"); - let root = resolve_project_from_file(config) + let config = load_project_config(config_path).expect("project config should load"); + let root = WorkflowSettingsBuilder::project_from_layer(&config) .expect("project settings should resolve") .directory; normalize_joined_path(project_dir, Path::new(&root)) @@ -408,38 +397,38 @@ mod tests { #[test] fn parse_minimal_config() { - let config = parse_project_config("_version = 1\n").unwrap(); + let config = "_version = 1\n".parse::().unwrap(); assert_eq!(config.version, Some(1)); assert!(config.project.is_none()); } #[test] fn parse_with_project_directory() { - let config = parse_project_config( - r#" + assert_eq!( + WorkflowSettingsBuilder::from_toml( + r#" _version = 1 [project] directory = "custom/" "#, - ) - .unwrap(); - assert_eq!( - resolve_project_from_file(&config).unwrap().directory, + ) + .unwrap() + .project + .directory, "custom/" ); } #[test] fn parse_with_run_execution_retros() { - let config = parse_project_config( - " + let config = " _version = 1 [run.execution] retros = true -", - ) +" + .parse::() .unwrap(); assert_eq!( config @@ -453,7 +442,9 @@ retros = true #[test] fn parse_rejects_legacy_llm_section() { - let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err(); + let err = "_version = 1\n[llm]\nprovider = \"openai\"\n" + .parse::() + .unwrap_err(); let text = format!("{err:#}"); assert!( text.contains("run.model") || text.contains("llm"), @@ -463,7 +454,9 @@ retros = true #[test] fn parse_higher_version_errors() { - let err = parse_project_config("_version = 2\n").unwrap_err(); + let err = "_version = 2\n" + .parse::() + .unwrap_err(); let chain = format!("{err:#}"); assert!( chain.contains("Upgrade") || chain.to_lowercase().contains("version"), @@ -491,14 +484,13 @@ retros = true let sub = tmp.path().join("sub").join("dir"); fs::create_dir_all(&sub).unwrap(); - let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); + let found_path = discover_project_config(&sub).unwrap().unwrap(); assert_eq!(found_path, config_dir.join("project.toml")); - assert_eq!(config.version, Some(1)); } #[test] fn load_project_config_rewrites_relative_goal_file_path() { - use fabro_types::settings::run::RunGoalLayer; + use crate::RunGoalLayer; let tmp = TempDir::new().unwrap(); let config_dir = tmp.path().join(".fabro"); @@ -532,9 +524,7 @@ file = "prompts/goal.md" let config_path = config_dir.join("project.toml"); fs::write(&config_path, "_version = 1\n").unwrap(); - let config = load_project_config(&config_path).unwrap(); - - assert_eq!(resolve_fabro_root(&config_path, &config), config_dir); + assert_eq!(resolve_fabro_root(&config_path), config_dir); } #[test] @@ -553,17 +543,12 @@ directory = "../custom" ) .unwrap(); - let config = load_project_config(&config_path).unwrap(); - - assert_eq!( - resolve_fabro_root(&config_path, &config), - tmp.path().join("custom") - ); + assert_eq!(resolve_fabro_root(&config_path), tmp.path().join("custom")); } #[test] fn relative_goal_file_resolves_from_config_dir() { - use fabro_types::settings::run::RunGoalLayer; + use crate::RunGoalLayer; let tmp = TempDir::new().unwrap(); let config_dir = tmp.path().join(".fabro"); @@ -591,4 +576,18 @@ file = "prompts/goal.md" config_dir.join("prompts").join("goal.md").to_string_lossy() ); } + + #[test] + fn resolve_working_directory_from_run_joins_relative_path() { + let cwd = Path::new("/tmp/workspace"); + let resolved = resolve_working_directory_from_run( + &RunNamespace { + working_dir: Some(InterpString::parse("repo")), + ..RunNamespace::default() + }, + cwd, + ); + + assert_eq!(resolved, cwd.join("repo")); + } } diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/crates/fabro-config/src/resolve/cli.rs index 2c7e09345..9a58520e4 100644 --- a/lib/crates/fabro-config/src/resolve/cli.rs +++ b/lib/crates/fabro-config/src/resolve/cli.rs @@ -1,10 +1,10 @@ use fabro_types::settings::cli::{ - CliAuthSettings, CliExecAgentSettings, CliExecLayer, CliExecModelSettings, CliExecSettings, - CliLayer, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetLayer, - CliTargetSettings, CliUpdatesSettings, + CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings, + CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings, }; use super::{ResolveError, require_interp}; +use crate::{CliExecLayer, CliLayer, CliTargetLayer}; pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliNamespace { CliNamespace { diff --git a/lib/crates/fabro-config/src/resolve/features.rs b/lib/crates/fabro-config/src/resolve/features.rs index a9a2d1254..ca2de9975 100644 --- a/lib/crates/fabro-config/src/resolve/features.rs +++ b/lib/crates/fabro-config/src/resolve/features.rs @@ -1,6 +1,7 @@ -use fabro_types::settings::features::{FeaturesLayer, FeaturesNamespace}; +use fabro_types::settings::FeaturesNamespace; use super::ResolveError; +use crate::FeaturesLayer; pub fn resolve_features( layer: &FeaturesLayer, diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 754cba237..0a9ca6528 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -8,89 +8,13 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; -use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, - SettingsLayer, WorkflowNamespace, -}; +use fabro_types::settings::InterpString; pub use features::resolve_features; pub use project::resolve_project; pub use run::resolve_run; -pub use server::{dev_token_auth_enabled, resolve_server}; +pub use server::resolve_server; pub use workflow::resolve_workflow; -use crate::apply_builtin_defaults; -use crate::user::default_storage_dir; - -pub fn resolve_storage_root(file: &SettingsLayer) -> InterpString { - let layer = apply_builtin_defaults(file.clone()); - layer - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.clone()) - .unwrap_or_else(|| default_interp(default_storage_dir())) -} - -pub fn resolve_cli_from_file(file: &SettingsLayer) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -pub fn resolve_server_from_file( - file: &SettingsLayer, -) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -pub fn resolve_project_from_file( - file: &SettingsLayer, -) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -pub fn resolve_features_from_file( - file: &SettingsLayer, -) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -pub fn resolve_run_from_file(file: &SettingsLayer) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -pub fn resolve_workflow_from_file( - file: &SettingsLayer, -) -> Result> { - let layer = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let value = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); - finish(value, errors) -} - -/// Render a list of [`ResolveError`]s as a single semicolon-separated message -/// for human-facing error envelopes. -pub fn render_resolve_errors(errors: &[ResolveError]) -> String { - errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("; ") -} - pub(crate) fn require_interp( value: Option<&InterpString>, path: &str, @@ -126,27 +50,17 @@ pub(crate) fn default_interp(path: impl AsRef) -> InterpString InterpString::parse(&path.as_ref().to_string_lossy()) } -fn finish(value: T, errors: Vec) -> Result> { - if errors.is_empty() { - Ok(value) - } else { - Err(errors) - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; use fabro_types::settings::run::{HookType, McpTransport, TlsMode}; - use super::resolve_run_from_file; - use crate::parse_settings_layer; + use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolve_preserves_source_templates_for_mcp_and_hook_strings() { - let settings = parse_settings_layer( - r#" + let settings = r#" _version = 1 [server.auth] @@ -181,11 +95,13 @@ url = "https://hooks.example.com" [run.hooks.headers] Authorization = "Bearer {{ env.HOOK_TOKEN }}" -"#, - ) +"# + .parse::() .expect("settings fixture should parse"); - let resolved = resolve_run_from_file(&settings).expect("run settings should resolve"); + let resolved = WorkflowSettingsBuilder::from_layer(&settings) + .expect("run settings should resolve") + .run; let mcps = &resolved.agent.mcps; assert_eq!( diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/crates/fabro-config/src/resolve/project.rs index 3fb6d0a6e..2296acc5c 100644 --- a/lib/crates/fabro-config/src/resolve/project.rs +++ b/lib/crates/fabro-config/src/resolve/project.rs @@ -1,6 +1,7 @@ -use fabro_types::settings::project::{ProjectLayer, ProjectNamespace}; +use fabro_types::settings::ProjectNamespace; use super::ResolveError; +use crate::ProjectLayer; pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> ProjectNamespace { ProjectNamespace { diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 8c918685f..852244c66 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -1,19 +1,22 @@ use fabro_types::settings::InterpString; use fabro_types::settings::run::{ - ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSettings, - DaytonaSnapshotSettings, DockerfileSource, GitAuthorSettings, HookAgentMarker, HookDefinition, - HookEntry, HookTlsMode, HookType, InterviewProviderLayer, InterviewProviderSettings, - InterviewsLayer, LocalSandboxSettings, McpEntryLayer, McpServerSettings, McpTransport, - MergeStrategy, ModelRefOrSplice, NotificationProviderLayer, NotificationProviderSettings, - NotificationRouteLayer, NotificationRouteSettings, PullRequestSettings, RunAgentLayer, - RunAgentSettings, RunArtifactsLayer, RunCheckpointLayer, RunCheckpointSettings, - RunExecutionLayer, RunExecutionSettings, RunGitLayer, RunGitSettings, RunGoal, RunGoalLayer, - RunInterviewsSettings, RunLayer, RunModelLayer, RunModelSettings, RunNamespace, - RunPrepareLayer, RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings, - RunScmLayer, RunScmSettings, ScmGitHubSettings, StringOrSplice, TlsMode, + ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, + GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, LocalSandboxSettings, + McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings, + NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings, + RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelSettings, + RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings, + TlsMode, }; use super::ResolveError; +use crate::{ + DaytonaDockerfileLayer, DaytonaSandboxLayer, HookAgentMarker, HookEntry, HookTlsMode, + InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, + NotificationProviderLayer, NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, + RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, StringOrSplice, +}; pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNamespace { RunNamespace { @@ -451,7 +454,7 @@ fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings { provider: scm.provider.clone(), owner: scm.owner.clone(), repository: scm.repository.clone(), - github: scm.github.as_ref().map(|_| ScmGitHubSettings), + github: scm.github.as_ref().map(|_| ScmGitHubSettings {}), } } diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 54cbf8ed9..eeb8371ca 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -1,30 +1,23 @@ +use fabro_types::settings::InterpString; use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, - IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, - ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, - ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, - ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, - ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, - ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, - ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, - ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, - ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, - WebhookStrategy, + IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings, + ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, + ServerAuthSettings, ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, + ServerIpAllowlistSettings, ServerListenSettings, ServerLoggingSettings, ServerNamespace, + ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, + SlackIntegrationSettings, TeamsIntegrationSettings, WebhookStrategy, }; -use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_util::Home; use super::{ResolveError, default_interp, parse_socket_addr, require_interp}; use crate::user::default_storage_dir; - -pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { - layer - .server - .as_ref() - .and_then(|server| server.auth.as_ref()) - .and_then(|auth| auth.methods.as_ref()) - .is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken)) -} +use crate::{ + IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, + ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, + ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer, + ServerStorageLayer, ServerWebLayer, +}; pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerNamespace { let storage = resolve_storage(layer.storage.as_ref()); diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/crates/fabro-config/src/resolve/workflow.rs index 272c0437f..d6db3a897 100644 --- a/lib/crates/fabro-config/src/resolve/workflow.rs +++ b/lib/crates/fabro-config/src/resolve/workflow.rs @@ -1,6 +1,7 @@ -use fabro_types::settings::workflow::{WorkflowLayer, WorkflowNamespace}; +use fabro_types::settings::WorkflowNamespace; use super::ResolveError; +use crate::WorkflowLayer; pub fn resolve_workflow( layer: &WorkflowLayer, diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 2cc603978..86e4f0866 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -1,9 +1,8 @@ //! Workflow / run config loading helpers. //! -//! Thin wrappers around `parse_settings_layer` / `load_settings_path` 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. +//! Helpers for loading workflow-local settings and resolving runtime goal / +//! graph paths. Runtime types that used to be re-exported from here live +//! under `fabro_types::settings::run` now. #![expect( clippy::disallowed_methods, @@ -12,24 +11,17 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer}; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunNamespace}; use crate::load::{load_settings_path, resolve_goal_file_path}; -use crate::parse::parse_settings_layer; -use crate::{Error, Result}; - -/// Load and parse a run config from a TOML file. -pub fn parse_run_config(contents: &str) -> Result { - parse_settings_layer(contents) - .map_err(|err| Error::parse("Failed to parse run config TOML", err)) -} +use crate::{Result, RunGoalLayer, RunLayer, SettingsLayer}; /// Load and parse a run config from a TOML file. /// /// Goes through [`load_settings_path`] so that relative `run.goal.file` /// paths are anchored at the directory of `path` at load time. -pub fn load_run_config(path: &Path) -> Result { +pub(crate) fn load_run_config(path: &Path) -> Result { load_settings_path(path) } @@ -76,42 +68,78 @@ impl std::error::Error for ResolveRunGoalError { } } -pub fn resolve_run_goal( - settings: &SettingsLayer, +pub fn resolve_run_goal_from_layer( + run: &RunLayer, base_dir: &Path, ) -> std::result::Result, ResolveRunGoalError> { - let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else { + let Some(goal) = run.goal.as_ref() else { return Ok(None); }; + resolve_layer_goal(goal, base_dir).map(Some) +} + +pub fn resolve_run_goal_from_namespace( + run: &RunNamespace, + base_dir: &Path, +) -> std::result::Result, ResolveRunGoalError> { + let Some(goal) = run.goal.as_ref() else { + return Ok(None); + }; + + resolve_goal(goal, base_dir).map(Some) +} + +fn resolve_goal_file( + file: &InterpString, + base_dir: &Path, +) -> std::result::Result { + let resolved = file + .resolve(|name| std::env::var(name).ok()) + .map_err(|err| ResolveRunGoalError::EnvLookup { var: err.name })?; + let path = resolve_goal_file_path(&resolved.value, base_dir); + let text = std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io { + path: path.clone(), + source, + })?; + Ok(ResolvedRunGoal { + text, + source: ResolvedGoalSource::File { path }, + }) +} + +fn resolve_layer_goal( + goal: &RunGoalLayer, + base_dir: &Path, +) -> std::result::Result { match goal { - RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal { + RunGoalLayer::Inline(text) => Ok(ResolvedRunGoal { text: text.as_source(), source: ResolvedGoalSource::Inline, - })), - RunGoalLayer::File { file } => { - let resolved = file - .resolve(|name| std::env::var(name).ok()) - .map_err(|err| ResolveRunGoalError::EnvLookup { var: err.name })?; - let path = resolve_goal_file_path(&resolved.value, base_dir); - let text = - std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io { - path: path.clone(), - source, - })?; - Ok(Some(ResolvedRunGoal { - text, - source: ResolvedGoalSource::File { path }, - })) - } + }), + RunGoalLayer::File { file } => resolve_goal_file(file, base_dir), + } +} + +fn resolve_goal( + goal: &RunGoal, + base_dir: &Path, +) -> std::result::Result { + match goal { + RunGoal::Inline(text) => Ok(ResolvedRunGoal { + text: text.as_source(), + source: ResolvedGoalSource::Inline, + }), + RunGoal::File(file) => resolve_goal_file(file, base_dir), } } #[cfg(test)] mod tests { - use fabro_types::settings::run::RunGoalLayer; + use fabro_types::settings::run::RunGoal; use super::*; + use crate::RunGoalLayer; #[test] fn load_run_config_rewrites_relative_goal_file_path() { @@ -161,4 +189,28 @@ file = "/etc/fabro/goal.md" }; assert_eq!(file.as_source(), "/etc/fabro/goal.md"); } + + #[test] + fn resolve_run_goal_from_namespace_reads_file_goal() { + let tmp = tempfile::tempdir().unwrap(); + let goal_path = tmp.path().join("goal.md"); + std::fs::write(&goal_path, "ship from namespace").unwrap(); + + let resolved = resolve_run_goal_from_namespace( + &RunNamespace { + goal: Some(RunGoal::File(InterpString::parse( + &goal_path.display().to_string(), + ))), + ..RunNamespace::default() + }, + tmp.path(), + ) + .unwrap() + .expect("goal should resolve"); + + assert_eq!(resolved.text, "ship from namespace"); + assert_eq!(resolved.source, ResolvedGoalSource::File { + path: goal_path, + }); + } } diff --git a/lib/crates/fabro-config/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs similarity index 96% rename from lib/crates/fabro-config/tests/combine.rs rename to lib/crates/fabro-config/src/tests/combine.rs index 55aff3b1c..be3677265 100644 --- a/lib/crates/fabro-config/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -1,9 +1,12 @@ +use fabro_types::settings::InterpString; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; -use fabro_types::settings::run::StringOrSplice; -use fabro_types::settings::{Combine, InterpString, SettingsLayer}; + +use crate::{Combine, SettingsLayer, StringOrSplice}; fn parse(input: &str) -> SettingsLayer { - fabro_config::parse_settings_layer(input).expect("fixture should parse") + input + .parse::() + .expect("fixture should parse") } #[test] diff --git a/lib/crates/fabro-config/tests/defaults.rs b/lib/crates/fabro-config/src/tests/defaults.rs similarity index 69% rename from lib/crates/fabro-config/tests/defaults.rs rename to lib/crates/fabro-config/src/tests/defaults.rs index dc5aa78c3..da17b5dc3 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -1,19 +1,22 @@ -use fabro_config::{ - apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve_run_from_file, - resolve_server_from_file, resolve_workflow_from_file, -}; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; use fabro_types::settings::server::ObjectStoreProvider; +use crate::{Combine, ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder}; + fn parse(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") + source + .parse::() + .expect("fixture should parse") +} + +fn embedded_defaults() -> SettingsLayer { + parse(include_str!("../defaults.toml")) } #[test] fn embedded_defaults_parse_successfully() { - let defaults = defaults_layer(); + let defaults = embedded_defaults(); assert_eq!( defaults @@ -33,7 +36,7 @@ fn embedded_defaults_parse_successfully() { #[test] fn apply_builtin_defaults_materializes_expected_layer() { - let layer = apply_builtin_defaults(SettingsLayer::default()); + let layer = SettingsLayer::default().combine(embedded_defaults()); assert_eq!( layer @@ -94,15 +97,19 @@ fn apply_builtin_defaults_materializes_expected_layer() { #[test] fn resolve_empty_settings_requires_explicit_server_auth_methods() { - let errors = resolve_server_from_file(&SettingsLayer::default()) + let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default()) .expect_err("empty server settings should fail"); - assert!(errors.iter().any(|error| { - matches!( - error, - fabro_config::ResolveError::Missing { path } if path == "server.auth.methods" - ) - })); + assert!(matches!( + errors, + fabro_config::Error::Resolve { errors, .. } + if errors.iter().any(|error| { + matches!( + error, + fabro_config::ResolveError::Missing { path } if path == "server.auth.methods" + ) + }) + )); } #[test] @@ -119,10 +126,10 @@ mode = "dry_run" "#, ); - let workflow = resolve_workflow_from_file(&layer).expect("workflow settings should resolve"); - let run = resolve_run_from_file(&layer).expect("run settings should resolve"); + let settings = + WorkflowSettingsBuilder::from_layer(&layer).expect("workflow settings should resolve"); - assert_eq!(run.execution.mode, RunMode::DryRun); - assert_eq!(run.execution.approval, ApprovalMode::Prompt); - assert_eq!(workflow.graph, "workflow.fabro"); + assert_eq!(settings.run.execution.mode, RunMode::DryRun); + assert_eq!(settings.run.execution.approval, ApprovalMode::Prompt); + assert_eq!(settings.workflow.graph, "workflow.fabro"); } diff --git a/lib/crates/fabro-config/src/tests/mod.rs b/lib/crates/fabro-config/src/tests/mod.rs new file mode 100644 index 000000000..9e1d8122b --- /dev/null +++ b/lib/crates/fabro-config/src/tests/mod.rs @@ -0,0 +1,9 @@ +mod combine; +mod defaults; +mod resolve_cli; +mod resolve_features; +mod resolve_project; +mod resolve_root; +mod resolve_run; +mod resolve_server; +mod resolve_workflow; diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/src/tests/resolve_cli.rs similarity index 73% rename from lib/crates/fabro-config/tests/resolve_cli.rs rename to lib/crates/fabro-config/src/tests/resolve_cli.rs index 01c05b60a..76a8277b4 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/src/tests/resolve_cli.rs @@ -3,17 +3,20 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::{parse_settings_layer, resolve_cli_from_file}; +use fabro_types::settings::InterpString; use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::AgentPermissions; -use fabro_types::settings::{InterpString, SettingsLayer}; use temp_env::with_var; +use crate::{SettingsLayer, UserSettingsBuilder}; + #[test] fn resolves_cli_defaults_from_empty_settings() { let settings = SettingsLayer::default(); - let cli = resolve_cli_from_file(&settings).expect("empty settings should resolve"); + let cli = UserSettingsBuilder::from_layer(&settings) + .expect("empty settings should resolve") + .cli; assert!(cli.target.is_none()); assert_eq!(cli.output.format, OutputFormat::Text); @@ -25,7 +28,7 @@ fn resolves_cli_defaults_from_empty_settings() { #[test] fn user_settings_from_layer_matches_namespace_resolvers() { - let settings: SettingsLayer = parse_settings_layer( + let user_settings = fabro_config::UserSettingsBuilder::from_toml( r#" _version = 1 @@ -37,20 +40,15 @@ url = "https://config.example.com" session_sandboxes = true "#, ) - .expect("fixture should parse"); - - let user_settings = - fabro_config::UserSettings::from_layer(&settings).expect("user settings should resolve"); + .expect("user settings should resolve"); assert_eq!( - user_settings.cli, - resolve_cli_from_file(&settings).expect("cli namespace should resolve") - ); - assert_eq!( - user_settings.features, - fabro_config::resolve_features_from_file(&settings) - .expect("features namespace should resolve") + user_settings.cli.target, + Some(CliTargetSettings::Http { + url: InterpString::parse("https://config.example.com"), + }) ); + assert!(user_settings.features.session_sandboxes); } #[test] @@ -71,8 +69,8 @@ session_sandboxes = true .unwrap(); with_var("FABRO_HOME", Some(home.path()), || { - let user_settings = - fabro_config::UserSettings::resolve().expect("user settings should resolve"); + let user_settings = fabro_config::UserSettingsBuilder::load_default() + .expect("user settings should resolve"); assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Verbose); assert!(user_settings.features.session_sandboxes); }); @@ -83,8 +81,8 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing( let home = tempfile::tempdir().unwrap(); with_var("FABRO_HOME", Some(home.path()), || { - let user_settings = - fabro_config::UserSettings::resolve().expect("user settings should resolve"); + let user_settings = fabro_config::UserSettingsBuilder::load_default() + .expect("user settings should resolve"); assert_eq!(user_settings.cli.output.format, OutputFormat::Text); assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Normal); assert!(!user_settings.features.session_sandboxes); @@ -93,7 +91,7 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing( #[test] fn resolves_cli_target_exec_and_output_settings() { - let settings: SettingsLayer = parse_settings_layer( + let cli = UserSettingsBuilder::from_toml( r#" _version = 1 @@ -126,9 +124,8 @@ check = false level = "debug" "#, ) - .expect("fixture should parse"); - - let cli = resolve_cli_from_file(&settings).expect("cli settings should resolve"); + .expect("cli settings should resolve") + .cli; let CliTargetSettings::Http { url } = cli.target.expect("target") else { panic!("expected http target"); diff --git a/lib/crates/fabro-config/src/tests/resolve_features.rs b/lib/crates/fabro-config/src/tests/resolve_features.rs new file mode 100644 index 000000000..ce197d12f --- /dev/null +++ b/lib/crates/fabro-config/src/tests/resolve_features.rs @@ -0,0 +1,28 @@ +use crate::{SettingsLayer, UserSettingsBuilder}; + +#[test] +fn resolves_features_defaults_from_empty_settings() { + let settings = SettingsLayer::default(); + + let features = UserSettingsBuilder::from_layer(&settings) + .expect("empty settings should resolve") + .features; + + assert!(!features.session_sandboxes); +} + +#[test] +fn resolves_session_sandboxes_flag() { + let features = UserSettingsBuilder::from_toml( + r" +_version = 1 + +[features] +session_sandboxes = true +", + ) + .expect("features should resolve") + .features; + + assert!(features.session_sandboxes); +} diff --git a/lib/crates/fabro-config/tests/resolve_project.rs b/lib/crates/fabro-config/src/tests/resolve_project.rs similarity index 65% rename from lib/crates/fabro-config/tests/resolve_project.rs rename to lib/crates/fabro-config/src/tests/resolve_project.rs index 345cf9f53..21c62d00c 100644 --- a/lib/crates/fabro-config/tests/resolve_project.rs +++ b/lib/crates/fabro-config/src/tests/resolve_project.rs @@ -1,11 +1,12 @@ -use fabro_config::{parse_settings_layer, resolve_project_from_file}; -use fabro_types::settings::SettingsLayer; +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_project_defaults_from_empty_settings() { let settings = SettingsLayer::default(); - let project = resolve_project_from_file(&settings).expect("empty settings should resolve"); + let project = WorkflowSettingsBuilder::from_layer(&settings) + .expect("empty settings should resolve") + .project; assert_eq!(project.directory, "."); assert!(project.name.is_none()); @@ -15,7 +16,7 @@ fn resolves_project_defaults_from_empty_settings() { #[test] fn resolves_project_directory_and_metadata() { - let settings: SettingsLayer = parse_settings_layer( + let project = WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -28,9 +29,8 @@ directory = ".fabro" team = "platform" "#, ) - .expect("fixture should parse"); - - let project = resolve_project_from_file(&settings).expect("project settings should resolve"); + .expect("project settings should resolve") + .project; assert_eq!(project.name.as_deref(), Some("Acme")); assert_eq!(project.description.as_deref(), Some("Automation")); diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/src/tests/resolve_root.rs similarity index 53% rename from lib/crates/fabro-config/tests/resolve_root.rs rename to lib/crates/fabro-config/src/tests/resolve_root.rs index f0f79c275..1d75d6221 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/src/tests/resolve_root.rs @@ -1,28 +1,28 @@ -use fabro_config::parse_settings_layer; +use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; -use fabro_types::settings::{InterpString, SettingsLayer}; -fn parse(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") -} +use crate::{ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_root_settings_require_explicit_server_auth_methods() { - let errors = fabro_config::resolve_server_from_file(&SettingsLayer::default()) + let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default()) .expect_err("empty server settings should fail"); - assert!(errors.iter().any(|error| { - matches!( - error, - fabro_config::ResolveError::Missing { path } if path == "server.auth.methods" - ) - })); + assert!(matches!( + errors, + fabro_config::Error::Resolve { errors, .. } + if errors.iter().any(|error| { + matches!( + error, + fabro_config::ResolveError::Missing { path } if path == "server.auth.methods" + ) + }) + )); } #[test] fn resolve_accumulates_errors_across_namespaces() { - let settings = parse( - r#" + let source = r#" _version = 1 [server.listen] @@ -37,21 +37,28 @@ allowed_usernames = [] [run.sandbox] provider = "not-a-provider" -"#, - ); +"#; let mut rendered = Vec::new(); rendered.extend( - fabro_config::resolve_server_from_file(&settings) + match ServerSettingsBuilder::from_toml(source) .expect_err("invalid server settings should fail") - .into_iter() - .map(|error| error.to_string()), + { + fabro_config::Error::Resolve { errors, .. } => errors, + other => panic!("expected resolve error, got {other:#}"), + } + .into_iter() + .map(|error| error.to_string()), ); rendered.extend( - fabro_config::resolve_run_from_file(&settings) + match fabro_config::WorkflowSettingsBuilder::from_toml(source) .expect_err("invalid run settings should fail") - .into_iter() - .map(|error| error.to_string()), + { + fabro_config::Error::Resolve { errors, .. } => errors, + other => panic!("expected resolve error, got {other:#}"), + } + .into_iter() + .map(|error| error.to_string()), ); let rendered = rendered.join("\n"); @@ -62,8 +69,7 @@ provider = "not-a-provider" #[test] fn namespace_resolvers_cover_root_level_settings_shape() { - let settings = parse( - r#" + let source = r#" _version = 1 [project] @@ -80,26 +86,31 @@ methods = ["dev-token"] [run.model] provider = "openai" name = "gpt-5" -"#, - ); +"#; - let project = fabro_config::resolve_project_from_file(&settings) - .expect("project settings should resolve"); - let workflow = fabro_config::resolve_workflow_from_file(&settings) - .expect("workflow settings should resolve"); - let server = - fabro_config::resolve_server_from_file(&settings).expect("server settings should resolve"); - let run = fabro_config::resolve_run_from_file(&settings).expect("run settings should resolve"); + let workflow_settings = + WorkflowSettingsBuilder::from_toml(source).expect("workflow settings should resolve"); + let server = ServerSettingsBuilder::from_toml(source).expect("server settings should resolve"); - assert_eq!(project.directory, ".fabro"); - assert_eq!(workflow.graph, "graphs/workflow.dot"); - assert_eq!(server.storage.root.as_source(), "/srv/fabro"); + assert_eq!(workflow_settings.project.directory, ".fabro"); + assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot"); + assert_eq!(server.server.storage.root.as_source(), "/srv/fabro"); assert_eq!( - run.model.provider.as_ref().map(InterpString::as_source), + workflow_settings + .run + .model + .provider + .as_ref() + .map(InterpString::as_source), Some("openai".to_string()) ); assert_eq!( - run.model.name.as_ref().map(InterpString::as_source), + workflow_settings + .run + .model + .name + .as_ref() + .map(InterpString::as_source), Some("gpt-5".to_string()) ); } @@ -107,8 +118,8 @@ name = "gpt-5" #[test] fn workflow_settings_resolve_defaults_and_expose_fields() { let settings = SettingsLayer::default(); - let resolved = - fabro_config::WorkflowSettings::from_layer(&settings).expect("defaults should resolve"); + let resolved = fabro_config::WorkflowSettingsBuilder::from_layer(&settings) + .expect("defaults should resolve"); assert_eq!(resolved.project.directory, "."); assert_eq!(resolved.workflow.graph, "workflow.fabro"); @@ -117,7 +128,7 @@ fn workflow_settings_resolve_defaults_and_expose_fields() { #[test] fn workflow_settings_combine_labels_with_later_namespaces_winning() { - let settings = parse( + let labels = fabro_config::WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -133,11 +144,9 @@ shared = "workflow" run = "yes" shared = "run" "#, - ); - - let labels = fabro_config::WorkflowSettings::from_layer(&settings) - .expect("workflow settings should resolve") - .combined_labels(); + ) + .expect("workflow settings should resolve") + .combined_labels(); assert_eq!(labels.get("project").map(String::as_str), Some("yes")); assert_eq!(labels.get("workflow").map(String::as_str), Some("yes")); @@ -147,17 +156,19 @@ shared = "run" #[test] fn workflow_settings_report_invalid_run_sandbox_provider() { - let settings = parse( + let errors = match fabro_config::WorkflowSettingsBuilder::from_toml( r#" _version = 1 [run.sandbox] provider = "not-a-provider" "#, - ); - - let errors = fabro_config::WorkflowSettings::from_layer(&settings) - .expect_err("invalid workflow settings should fail"); + ) + .expect_err("invalid workflow settings should fail") + { + fabro_config::Error::Resolve { errors, .. } => errors, + other => panic!("expected resolve error, got {other:#}"), + }; assert!(errors.iter().any(|error| { matches!( @@ -169,7 +180,7 @@ provider = "not-a-provider" #[test] fn workflow_settings_accumulate_multiple_run_errors() { - let settings = parse( + let rendered = fabro_config::WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -180,14 +191,9 @@ provider = "not-a-provider" script = "echo hi" command = ["echo", "hi"] "#, - ); - - let rendered = fabro_config::WorkflowSettings::from_layer(&settings) - .expect_err("invalid workflow settings should fail") - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n"); + ) + .expect_err("invalid workflow settings should fail") + .to_string(); assert!(rendered.contains("run.sandbox.provider")); assert!(rendered.contains("run.prepare.steps[0]")); diff --git a/lib/crates/fabro-config/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs similarity index 74% rename from lib/crates/fabro-config/tests/resolve_run.rs rename to lib/crates/fabro-config/src/tests/resolve_run.rs index 281916ea1..ac29ba325 100644 --- a/lib/crates/fabro-config/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -1,15 +1,13 @@ -use fabro_config::parse_settings_layer; +use fabro_types::settings::InterpString; use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode}; -use fabro_types::settings::{InterpString, SettingsLayer}; -fn parse(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") -} +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_run_defaults_from_empty_settings() { - let settings = fabro_config::resolve_run_from_file(&SettingsLayer::default()) - .expect("empty settings should resolve"); + let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default()) + .expect("empty settings should resolve") + .run; assert_eq!(settings.execution.mode, RunMode::Normal); assert_eq!(settings.execution.approval, ApprovalMode::Prompt); @@ -22,7 +20,7 @@ fn resolves_run_defaults_from_empty_settings() { #[test] fn preserves_goal_variants_and_model_sources() { - let file = parse( + let settings = WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -36,9 +34,9 @@ file = "{{ env.GOAL_FILE }}" provider = "anthropic" name = "sonnet" "#, - ); - - let settings = fabro_config::resolve_run_from_file(&file).expect("run settings should resolve"); + ) + .expect("run settings should resolve") + .run; match settings.goal { Some(RunGoal::File(path)) => { diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs similarity index 70% rename from lib/crates/fabro-config/tests/resolve_server.rs rename to lib/crates/fabro-config/src/tests/resolve_server.rs index 445fb834f..febec8178 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -3,17 +3,21 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::parse_settings_layer; -use fabro_config::user::default_storage_dir; +use fabro_types::settings::InterpString; use fabro_types::settings::server::{ - GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings, + GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod, + ServerListenSettings, ServerNamespace, }; -use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_util::Home; use temp_env::with_var; +use crate::user::default_storage_dir; +use crate::{ServerSettingsBuilder, SettingsLayer}; + fn parse(source: &str) -> SettingsLayer { - let mut layer = parse_settings_layer(source).expect("fixture should parse"); + let mut layer = source + .parse::() + .expect("fixture should parse"); layer.ensure_test_auth_methods(); layer } @@ -22,10 +26,39 @@ fn empty_settings_with_auth_methods() -> SettingsLayer { SettingsLayer::test_default() } +fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { + layer + .server + .as_ref() + .and_then(|server| server.auth.as_ref()) + .and_then(|auth| auth.methods.as_ref()) + .is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken)) +} + +fn resolve_server(file: &SettingsLayer) -> ServerNamespace { + ServerSettingsBuilder::from_layer(file) + .expect("server settings should resolve") + .server +} + +fn resolve_errors(error: fabro_config::Error) -> Vec { + match error { + fabro_config::Error::Resolve { errors, .. } => errors, + other => panic!("expected resolve error, got {other:#}"), + } +} + +fn render_resolve_error_lines(error: fabro_config::Error) -> String { + resolve_errors(error) + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n") +} + #[test] fn resolves_server_defaults_from_empty_settings() { - let settings = fabro_config::resolve_server_from_file(&empty_settings_with_auth_methods()) - .expect("server settings should resolve"); + let settings = resolve_server(&empty_settings_with_auth_methods()); assert_eq!( settings.storage.root.as_source(), @@ -92,18 +125,14 @@ session_sandboxes = true "#, ); - let context = - fabro_config::ServerSettings::from_layer(&settings).expect("settings should resolve"); + let context = fabro_config::ServerSettingsBuilder::from_layer(&settings) + .expect("settings should resolve"); + let user_settings = fabro_config::UserSettingsBuilder::from_layer(&settings) + .expect("user settings should resolve"); - assert_eq!( - context.server, - fabro_config::resolve_server_from_file(&settings).expect("server namespace should resolve") - ); - assert_eq!( - context.features, - fabro_config::resolve_features_from_file(&settings) - .expect("features namespace should resolve") - ); + assert_eq!(context.server.storage.root.as_source(), "/srv/fabro"); + assert!(context.features.session_sandboxes); + assert_eq!(context.features, user_settings.features); } #[test] @@ -127,7 +156,8 @@ session_sandboxes = true .unwrap(); with_var("FABRO_HOME", Some(home.path()), || { - let settings = fabro_config::ServerSettings::resolve().expect("settings should resolve"); + let settings = + fabro_config::ServerSettingsBuilder::load_default().expect("settings should resolve"); assert_eq!(settings.server.storage.root.as_source(), "/srv/from-home"); assert!(settings.features.session_sandboxes); }); @@ -135,8 +165,7 @@ session_sandboxes = true #[test] fn parsing_rejects_inbound_listener_tls_configuration() { - let err = fabro_config::parse_settings_layer( - r#" + let err = r#" _version = 1 [server.listen] @@ -145,8 +174,8 @@ address = "127.0.0.1:32276" [server.listen.tls] cert = "/etc/fabro/server.pem" -"#, - ) +"# + .parse::() .expect_err("listener TLS should be rejected at parse time"); assert!(err.to_string().contains("unknown field `tls`")); @@ -166,13 +195,10 @@ endpoint = "{{ env.S3_ENDPOINT }}" "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("s3 config without bucket/region should fail"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file) + .expect_err("s3 config without bucket/region should fail"), + ); assert!(rendered.contains("server.artifacts.s3.bucket")); assert!(rendered.contains("server.artifacts.s3.region")); @@ -195,8 +221,7 @@ slug = "fabro-app" "#, ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); match settings.listen { ServerListenSettings::Unix { path } => { @@ -230,8 +255,7 @@ strategy = "app" "#, ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); assert_eq!( settings.integrations.github.strategy, @@ -250,8 +274,7 @@ enabled = true ", ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); assert_eq!( settings.integrations.github.strategy, @@ -270,15 +293,14 @@ disk_cache = true ", ); - let settings = fabro_config::resolve_server_from_file(&file).expect("settings should resolve"); + let settings = resolve_server(&file); assert!(settings.slatedb.disk_cache); } #[test] fn resolves_empty_ip_allowlist_by_default() { - let settings = fabro_config::resolve_server_from_file(&empty_settings_with_auth_methods()) - .expect("server settings should resolve"); + let settings = resolve_server(&empty_settings_with_auth_methods()); assert!(settings.ip_allowlist.entries.is_empty()); assert_eq!(settings.ip_allowlist.trusted_proxy_count, 0); @@ -296,8 +318,7 @@ trusted_proxy_count = 2 "#, ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); assert_eq!(settings.ip_allowlist.entries, vec![ IpAllowEntry::parse_literal("10.0.0.0/8").unwrap(), @@ -322,8 +343,7 @@ entries = ["github_meta_hooks"] "#, ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); let webhook_allowlist = settings .integrations .github @@ -354,8 +374,7 @@ trusted_proxy_count = 3 "#, ); - let settings = - fabro_config::resolve_server_from_file(&file).expect("server settings should resolve"); + let settings = resolve_server(&file); let webhook_allowlist = settings .integrations .github @@ -382,13 +401,10 @@ strategy = "server_url" "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("server_url webhook strategy should require server.api.url"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file) + .expect_err("server_url webhook strategy should require server.api.url"), + ); assert!(rendered.contains("server.api.url")); } @@ -407,13 +423,9 @@ strategy = "tailscale_funnel" "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("configured webhook strategy should require server.integrations.github.app_id"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines(ServerSettingsBuilder::from_layer(&file).expect_err( + "configured webhook strategy should require server.integrations.github.app_id", + )); assert!(rendered.contains("server.integrations.github.app_id")); } @@ -429,13 +441,9 @@ entries = ["10.0.0.0/33"] "#, ); - let errors = - fabro_config::resolve_server_from_file(&file).expect_err("invalid CIDR should fail"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"), + ); assert!(rendered.contains("server.ip_allowlist.entries[0]")); } @@ -451,13 +459,10 @@ entries = ["github_meta_hooks"] "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("github_meta_hooks should be rejected outside github webhooks"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file) + .expect_err("github_meta_hooks should be rejected outside github webhooks"), + ); assert!(rendered.contains("server.ip_allowlist.entries[0]")); } @@ -477,13 +482,10 @@ entries = ["10.0.0.0/8"] "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("unix allowlist without trusted proxies should fail"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file) + .expect_err("unix allowlist without trusted proxies should fail"), + ); assert!(rendered.contains("server.ip_allowlist.trusted_proxy_count")); } @@ -503,13 +505,10 @@ entries = ["github_meta_hooks"] "#, ); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("unix github webhook allowlist without trusted proxies should fail"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); + let rendered = render_resolve_error_lines( + ServerSettingsBuilder::from_layer(&file) + .expect_err("unix github webhook allowlist without trusted proxies should fail"), + ); assert!( rendered.contains("server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count") @@ -517,9 +516,11 @@ entries = ["github_meta_hooks"] } #[test] -fn resolve_storage_root_defaults_without_server_auth_methods() { +fn resolve_storage_root_defaults_with_minimal_server_auth_methods() { + let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods()) + .expect("default server settings should resolve"); assert_eq!( - fabro_config::resolve_storage_root(&SettingsLayer::default()).as_source(), + settings.server.storage.root.as_source(), default_storage_dir().to_string_lossy() ); } @@ -534,11 +535,10 @@ _version = 1 root = "/srv/fabro" "#, ); + let settings = + ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve"); - assert_eq!( - fabro_config::resolve_storage_root(&file).as_source(), - "/srv/fabro" - ); + assert_eq!(settings.server.storage.root.as_source(), "/srv/fabro"); } #[test] @@ -551,9 +551,11 @@ _version = 1 root = "{{ env.FABRO_STORAGE_ROOT }}" "#, ); + let settings = + ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve"); assert_eq!( - fabro_config::resolve_storage_root(&file), + settings.server.storage.root, InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}") ); } @@ -585,10 +587,8 @@ methods = ["dev-token", "github"] "#, ); - assert!(fabro_config::dev_token_auth_enabled(&dev_token_only)); - assert!(!fabro_config::dev_token_auth_enabled(&github_only)); - assert!(fabro_config::dev_token_auth_enabled(&both)); - assert!(!fabro_config::dev_token_auth_enabled( - &SettingsLayer::default() - )); + assert!(dev_token_auth_enabled(&dev_token_only)); + assert!(!dev_token_auth_enabled(&github_only)); + assert!(dev_token_auth_enabled(&both)); + assert!(!dev_token_auth_enabled(&SettingsLayer::default())); } diff --git a/lib/crates/fabro-config/tests/resolve_workflow.rs b/lib/crates/fabro-config/src/tests/resolve_workflow.rs similarity index 65% rename from lib/crates/fabro-config/tests/resolve_workflow.rs rename to lib/crates/fabro-config/src/tests/resolve_workflow.rs index c5746caab..3e8eb3c9f 100644 --- a/lib/crates/fabro-config/tests/resolve_workflow.rs +++ b/lib/crates/fabro-config/src/tests/resolve_workflow.rs @@ -1,11 +1,12 @@ -use fabro_config::{parse_settings_layer, resolve_workflow_from_file}; -use fabro_types::settings::SettingsLayer; +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_workflow_defaults_from_empty_settings() { let settings = SettingsLayer::default(); - let workflow = resolve_workflow_from_file(&settings).expect("empty settings should resolve"); + let workflow = WorkflowSettingsBuilder::from_layer(&settings) + .expect("empty settings should resolve") + .workflow; assert_eq!(workflow.graph, "workflow.fabro"); assert!(workflow.name.is_none()); @@ -15,7 +16,7 @@ fn resolves_workflow_defaults_from_empty_settings() { #[test] fn resolves_workflow_graph_and_metadata() { - let settings: SettingsLayer = parse_settings_layer( + let workflow = WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -28,9 +29,8 @@ graph = "graphs/ship.dot" tier = "gold" "#, ) - .expect("fixture should parse"); - - let workflow = resolve_workflow_from_file(&settings).expect("workflow settings should resolve"); + .expect("workflow settings should resolve") + .workflow; assert_eq!(workflow.name.as_deref(), Some("Ship")); assert_eq!(workflow.description.as_deref(), Some("Primary flow")); diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 298cf2f48..49f36a0f4 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -6,11 +6,9 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::SettingsLayer; - -use crate::Result; use crate::home::Home; use crate::load::load_settings_path; +use crate::{Result, SettingsLayer}; pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml"; pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG"; @@ -43,7 +41,7 @@ fn active_settings_path_with_lookup( /// 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. -pub fn load_settings_config(path: Option<&Path>) -> Result { +pub(crate) fn load_settings_config(path: Option<&Path>) -> Result { if let Some(explicit) = path .map(Path::to_path_buf) .or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from)) @@ -63,24 +61,6 @@ fn load_v2_layer_from_path(path: &Path) -> Result { load_settings_path(path) } -/// Override the resolved storage root in a settings layer with a runtime path. -pub fn apply_storage_dir_override( - mut layer: SettingsLayer, - storage_dir: Option<&Path>, -) -> SettingsLayer { - use fabro_types::settings::interp::InterpString; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; - if let Some(dir) = storage_dir { - let server = layer.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 -} - #[cfg(test)] mod tests { use super::{ diff --git a/lib/crates/fabro-config/tests/resolve_features.rs b/lib/crates/fabro-config/tests/resolve_features.rs deleted file mode 100644 index 59c6a1740..000000000 --- a/lib/crates/fabro-config/tests/resolve_features.rs +++ /dev/null @@ -1,28 +0,0 @@ -use fabro_config::{parse_settings_layer, resolve_features_from_file}; -use fabro_types::settings::SettingsLayer; - -#[test] -fn resolves_features_defaults_from_empty_settings() { - let settings = SettingsLayer::default(); - - let features = resolve_features_from_file(&settings).expect("empty settings should resolve"); - - assert!(!features.session_sandboxes); -} - -#[test] -fn resolves_session_sandboxes_flag() { - let settings: SettingsLayer = parse_settings_layer( - r" -_version = 1 - -[features] -session_sandboxes = true -", - ) - .expect("fixture should parse"); - - let features = resolve_features_from_file(&settings).expect("features should resolve"); - - assert!(features.session_sandboxes); -} diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index 29d03911a..84bf86cce 100644 --- a/lib/crates/fabro-install/src/lib.rs +++ b/lib/crates/fabro-install/src/lib.rs @@ -468,7 +468,7 @@ pub fn persist_install_outputs_direct( #[cfg(test)] mod tests { - use fabro_config::{Storage, envfile}; + use fabro_config::{ServerSettingsBuilder, Storage, envfile}; use fabro_vault::{SecretType as VaultSecretType, Vault}; use super::{ @@ -492,16 +492,12 @@ mod tests { #[test] fn config_toml_has_auth_strategies() { - use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; + use fabro_types::settings::ServerAuthMethod; let toml_str = format_config_toml(); - let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); - let auth = cfg - .server - .as_ref() - .and_then(|s| s.auth.as_ref()) - .expect("server.auth should be set"); - assert_eq!(auth.methods, Some(vec![ServerAuthMethod::DevToken])); + let cfg = + ServerSettingsBuilder::from_toml(&toml_str).expect("generated config should resolve"); + assert_eq!(cfg.server.auth.methods, vec![ServerAuthMethod::DevToken]); } #[test] @@ -657,12 +653,11 @@ name = "custom" ) .unwrap(); - let settings = fabro_config::parse_settings_layer( + let resolved = ServerSettingsBuilder::from_toml( &toml::to_string_pretty(&doc).expect("settings should serialize"), ) - .expect("settings should parse"); - let resolved = - fabro_config::resolve_server_from_file(&settings).expect("settings should resolve"); + .expect("settings should resolve") + .server; match resolved.listen { ServerListenSettings::Tcp { address, .. } => { assert_eq!(address.to_string(), "0.0.0.0:32276"); diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/crates/fabro-macros/src/lib.rs index 94c9b38a1..aaf508520 100644 --- a/lib/crates/fabro-macros/src/lib.rs +++ b/lib/crates/fabro-macros/src/lib.rs @@ -174,12 +174,12 @@ fn impl_combine(ast: &DeriveInput) -> TokenStream { let combines = fields.iter().map(|field| { let name = &field.ident; quote! { - #name: crate::settings::Combine::combine(self.#name, other.#name) + #name: ::fabro_config::layers::Combine::combine(self.#name, other.#name) } }); quote! { - impl #impl_generics crate::settings::Combine for #name #ty_generics #where_clause { + impl #impl_generics ::fabro_config::layers::Combine for #name #ty_generics #where_clause { fn combine(self, other: Self) -> Self { Self { #(#combines),* diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index 4b052a40d..491772236 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -1301,12 +1301,9 @@ mod tests { use axum_extra::extract::cookie::Key; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use fabro_config::{RunLayer, ServerSettingsBuilder}; use fabro_types::RunAuthMethod; - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::server::{ - GithubIntegrationLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerAuthMethod, - ServerIntegrationsLayer, ServerLayer, ServerWebLayer, - }; + use fabro_types::settings::server::ServerAuthMethod; use serde_json::json; use sha2::{Digest, Sha256}; use tokio::sync::Barrier; @@ -1343,35 +1340,34 @@ mod tests { AuthMode::Enabled(config) } - fn github_settings(web_url: &str) -> SettingsLayer { - SettingsLayer { - server: Some(ServerLayer { - web: Some(ServerWebLayer { - enabled: Some(true), - url: Some(web_url.into()), - }), - auth: Some(ServerAuthLayer { - methods: Some(vec![ServerAuthMethod::Github]), - github: Some(ServerAuthGithubLayer { - allowed_usernames: vec!["octocat".to_string()], - }), - }), - integrations: Some(ServerIntegrationsLayer { - github: Some(GithubIntegrationLayer { - client_id: Some("github-client-id".into()), - ..GithubIntegrationLayer::default() - }), - ..ServerIntegrationsLayer::default() - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - } + fn github_settings(web_url: &str) -> fabro_types::ServerSettings { + ServerSettingsBuilder::from_toml(&format!( + r#" +_version = 1 + +[server.web] +enabled = true +url = "{web_url}" + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.integrations.github] +client_id = "github-client-id" +"# + )) + .expect("github settings should resolve") } - fn test_router(settings: SettingsLayer) -> (axum::Router, Arc) { - let state = server::create_test_app_state_with_session_key( + fn test_router( + settings: fabro_types::ServerSettings, + ) -> (axum::Router, Arc) { + let state = server::create_test_app_state_with_runtime_settings_and_session_key( settings, + RunLayer::default(), Some("cli-flow-test-key-material-0123456789"), ); let app = axum::Router::new() diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/crates/fabro-server/src/auth/translate.rs index 2d124f2c5..a2293676d 100644 --- a/lib/crates/fabro-server/src/auth/translate.rs +++ b/lib/crates/fabro-server/src/auth/translate.rs @@ -153,7 +153,8 @@ mod tests { use axum::routing::get; use axum::{Json, Router, middleware}; use cookie::{Cookie, CookieJar}; - use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; + use fabro_config::{RunLayer, ServerSettingsBuilder}; + use fabro_types::settings::ServerAuthMethod; use fabro_types::{IdpIdentity, RunAuthMethod}; use serde_json::json; use tower::ServiceExt; @@ -221,9 +222,22 @@ mod tests { .layer(middleware::from_fn(demo_routing_middleware)) } + fn test_server_settings() -> fabro_types::ServerSettings { + ServerSettingsBuilder::from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ) + .expect("test settings should resolve") + } + fn test_state() -> Arc { - server::create_test_app_state_with_session_key( - SettingsLayer::default(), + server::create_test_app_state_with_runtime_settings_and_session_key( + test_server_settings(), + RunLayer::default(), Some(SESSION_SECRET), ) } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 3709bd62d..ff487dd96 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -754,6 +754,12 @@ mod runs { use std::time::Duration; use fabro_api::types::*; + use fabro_types::WorkflowSettings; + use fabro_types::settings::run::{ + DaytonaSettings, DaytonaSnapshotSettings, LocalSandboxSettings, RunGoal, RunModelSettings, + RunNamespace, RunPrepareSettings, RunSandboxSettings, + }; + use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace}; use super::ts; use crate::server::truncate_goal; @@ -1338,39 +1344,57 @@ mod runs { } pub(super) fn settings() -> serde_json::Value { - // v2 SettingsLayer 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" + let settings = WorkflowSettings { + project: ProjectNamespace { + directory: "/workspace/api-server".into(), + ..ProjectNamespace::default() + }, + workflow: WorkflowNamespace { + graph: "workflow.fabro".into(), + ..WorkflowNamespace::default() + }, + run: RunNamespace { + goal: Some(RunGoal::Inline(InterpString::parse( + "Add rate limiting to auth endpoints", + ))), + working_dir: Some(InterpString::parse("/workspace/api-server")), + model: RunModelSettings { + provider: Some(InterpString::parse("anthropic")), + name: Some(InterpString::parse("claude-opus-4-6")), + ..RunModelSettings::default() }, - "prepare": { - "steps": [ - { "command": ["bun", "install"] }, - { "command": ["bun", "run", "typecheck"] } - ], - "timeout": "120s" + prepare: RunPrepareSettings { + commands: vec!["bun install".into(), "bun run typecheck".into()], + timeout_ms: 120_000, }, - "sandbox": { - "provider": "daytona", - "daytona": { - "auto_stop_interval": 60, - "labels": { "project": "api-server" }, - "snapshot": { - "name": "api-server-dev", - "cpu": 4, - "memory": "8GB", - "disk": "10GB" - } - } - } - } - }) + sandbox: RunSandboxSettings { + provider: "daytona".into(), + preserve: false, + devcontainer: false, + env: HashMap::new(), + local: LocalSandboxSettings::default(), + daytona: Some(DaytonaSettings { + auto_stop_interval: Some(60), + labels: HashMap::from([( + "project".to_string(), + "api-server".to_string(), + )]), + snapshot: Some(DaytonaSnapshotSettings { + name: "api-server-dev".into(), + cpu: Some(4), + memory_gb: Some(8), + disk_gb: Some(10), + dockerfile: None, + }), + network: None, + skip_clone: false, + }), + }, + ..RunNamespace::default() + }, + }; + + serde_json::to_value(settings).expect("demo workflow settings should serialize") } #[cfg(test)] @@ -1560,8 +1584,9 @@ mod settings { static CACHED: OnceLock = OnceLock::new(); CACHED .get_or_init(|| { - let settings = fabro_config::parse_settings_layer( - r#" + serde_json::to_value( + fabro_config::ServerSettingsBuilder::from_toml( + r#" _version = 1 [server.listen] @@ -1597,12 +1622,8 @@ slug = "fabro-dev" [features] session_sandboxes = false "#, - ) - .expect("demo settings fixture should parse"); - - serde_json::to_value( - fabro_config::ServerSettings::from_layer(&settings) - .expect("demo settings fixture should resolve"), + ) + .expect("demo settings fixture should resolve"), ) .expect("demo settings should serialize") }) diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 301d7a943..1dfb001eb 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -23,7 +23,7 @@ use fabro_install::{ }; use fabro_model::Provider; use fabro_store::ArtifactStore; -use fabro_types::settings::SettingsLayer; +use fabro_types::ServerSettings; use fabro_types::settings::interp::InterpString; use fabro_types::settings::server::ObjectStoreSettings; use fabro_util::version::FABRO_VERSION; @@ -1433,7 +1433,7 @@ async fn post_install_finish( .into_response(); } - if let Ok(settings) = fabro_config::parse_settings_layer(&settings_toml) { + if let Ok(settings) = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml) { if let Err(err) = write_artifact_store_metadata(&settings, state.storage_dir.as_ref()).await { warn!(error = %err, "failed to write artifact store metadata after install"); @@ -1888,22 +1888,14 @@ fn is_valid_github_manifest_code(code: &str) -> bool { } async fn write_artifact_store_metadata( - settings: &SettingsLayer, + settings: &ServerSettings, storage_dir: &Path, ) -> anyhow::Result<()> { use fabro_types::settings::interp::InterpString; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; let mut settings = settings.clone(); - 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(&storage_dir.display().to_string())); - - let resolved = - fabro_config::ServerSettings::from_layer(&settings).map_err(anyhow::Error::from)?; - let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?; + settings.server.storage.root = InterpString::parse(&storage_dir.display().to_string()); + let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(FABRO_VERSION).await?; Ok(()) @@ -1973,6 +1965,7 @@ mod tests { InstallObjectStoreInput, InstallObjectStoreProvider, PendingInstall, ServerSecrets, classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup, lock_unpoisoned, resolve_install_object_store_state, token_is_valid, + write_artifact_store_metadata, }; #[test] @@ -2045,6 +2038,46 @@ mod tests { ); } + #[tokio::test] + async fn write_artifact_store_metadata_creates_marker_in_overridden_storage_root() { + use object_store::path::Path as ObjectPath; + + let dir = tempfile::tempdir().unwrap(); + let settings = fabro_config::ServerSettingsBuilder::from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ) + .unwrap(); + + write_artifact_store_metadata(&settings, dir.path()) + .await + .unwrap(); + + let mut overridden = settings.clone(); + overridden.server.storage.root = + fabro_types::settings::interp::InterpString::parse(&dir.path().display().to_string()); + let (object_store, prefix) = + crate::serve::build_artifact_object_store(&overridden.server).unwrap(); + let marker = if prefix.is_empty() { + "store-metadata.json".to_string() + } else { + format!("{prefix}/store-metadata.json") + }; + let bytes = object_store + .get(&ObjectPath::from(marker)) + .await + .unwrap() + .bytes() + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["fabro_version"], super::FABRO_VERSION); + } + #[test] fn resolve_install_object_store_state_rejects_local_with_s3_fields() { let err = resolve_install_object_store_state(None, InstallObjectStoreInput { diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 5108282cd..6e1e7a7ea 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -376,7 +376,7 @@ mod tests { use axum::{Json, Router}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use fabro_config::{parse_settings_layer, resolve_server_from_file}; + use fabro_config::{Error as ConfigError, ServerSettingsBuilder}; use fabro_types::IdpIdentity; use fabro_types::settings::ServerAuthMethod; use tower::ServiceExt; @@ -387,8 +387,9 @@ mod tests { use super::*; fn settings(source: &str) -> ServerNamespace { - let file = parse_settings_layer(source).expect("fixture should parse"); - resolve_server_from_file(&file).expect("fixture should resolve") + ServerSettingsBuilder::from_toml(source) + .expect("fixture should resolve") + .server } fn empty_lookup(_name: &str) -> Option { @@ -548,7 +549,7 @@ mod tests { #[test] fn fails_when_auth_methods_empty() { - let file = parse_settings_layer( + let ConfigError::Resolve { errors, .. } = ServerSettingsBuilder::from_toml( r" _version = 1 @@ -556,8 +557,9 @@ _version = 1 methods = [] ", ) - .expect("fixture should parse"); - let errors = resolve_server_from_file(&file).expect_err("empty auth methods should fail"); + .expect_err("empty auth methods should fail") else { + panic!("expected settings resolution error"); + }; assert!(errors.iter().any(|err| matches!( err, fabro_config::ResolveError::Invalid { path, reason } diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 4186baf1a..c5cc7b87d 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,10 +4,10 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::effective_settings::EffectiveSettingsLayers; -use fabro_config::project::resolve_working_directory; -use fabro_config::run::parse_run_config; -use fabro_config::{effective_settings, parse_settings_layer}; +use fabro_config::{ + CliLayer, CliOutputLayer, DaytonaDockerfileLayer, ReplaceMap, RunExecutionLayer, RunLayer, + RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder, +}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; @@ -17,15 +17,14 @@ use fabro_sandbox::config::{ }; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; -use fabro_types::RunId; -use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; +use fabro_types::settings::ServerNamespace; +use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ - ApprovalMode, DaytonaDockerfileLayer, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource, - RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunNamespace, - RunSandboxLayer, + ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource, RunGoal, RunMode, + RunNamespace, }; -use fabro_types::settings::{Combine, ReplaceMap, ServerNamespace, SettingsLayer}; +use fabro_types::{RunId, WorkflowSettings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; @@ -43,15 +42,26 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub target_path: PathBuf, pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, pub working_directory: PathBuf, } +#[derive(Clone, Debug, Default)] +struct ManifestSettingsOverrides { + run: Option, + cli: Option, +} + +#[cfg(test)] +pub(crate) fn manifest_run_defaults(run: Option<&RunLayer>) -> RunLayer { + run.cloned().unwrap_or_default() +} + pub(crate) fn prepare_manifest( - server_settings: &SettingsLayer, + manifest_run_defaults: &RunLayer, manifest: &types::RunManifest, ) -> Result { if manifest.version != 1 { @@ -67,29 +77,43 @@ pub(crate) fn prepare_manifest( .ok_or_else(|| anyhow!("manifest target path is missing from workflows map"))?; let root_source = workflow_input.source.clone(); - let args_layer = manifest_args_layer(manifest.args.as_ref()); - let workflow_layer = root_workflow_config_layer(manifest, &workflow_input)?; - let project_layer = manifest + let args_overrides = manifest_args_overrides(manifest.args.as_ref()); + let workflow_run_layer = root_workflow_run_layer(manifest, &workflow_input)?; + let mut workflow_settings_builder = + WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone()); + if let Some(run) = args_overrides.run { + workflow_settings_builder = workflow_settings_builder.run_overrides(run); + } + if let Some(cli) = args_overrides.cli { + workflow_settings_builder = workflow_settings_builder.cli_overrides(cli); + } + if !workflow_run_layer.eq(&RunLayer::default()) { + workflow_settings_builder = + workflow_settings_builder.workflow_run_layer(workflow_run_layer); + } + for config in manifest .configs .iter() .filter(|config| config.type_ == types::ManifestConfigType::Project) - .try_fold(SettingsLayer::default(), |layer, config| { - Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) - })?; - let user_layer = manifest + { + if let Some(source) = config.source.as_deref() { + workflow_settings_builder = workflow_settings_builder.project_toml(source)?; + } + } + for config in manifest .configs .iter() .filter(|config| config.type_ == types::ManifestConfigType::User) - .try_fold(SettingsLayer::default(), |layer, config| { - Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) - })?; - let mut settings = effective_settings::materialize_settings_layer( - EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer), - Some(server_settings), - )?; + { + if let Some(source) = config.source.as_deref() { + workflow_settings_builder = workflow_settings_builder.user_toml(source)?; + } + } + let mut settings = workflow_settings_builder + .build() + .map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?; if let Some(goal) = manifest.goal.as_ref() { - let run = settings.run.get_or_insert_with(RunLayer::default); - run.goal = Some(RunGoalLayer::Inline(InterpString::parse(&goal.text))); + settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text))); } Ok(PreparedManifest { @@ -186,32 +210,34 @@ fn workflow_bundle_from_manifest( Ok(WorkflowBundle::new(workflows)) } -fn root_workflow_config_layer( +fn root_workflow_run_layer( manifest: &types::RunManifest, workflow: &BundledWorkflow, -) -> Result { +) -> Result { let Some(root) = manifest.workflows.get(&manifest.target.path) else { bail!("manifest target path is missing from workflows map"); }; let Some(config) = root.config.as_ref() else { - return Ok(SettingsLayer::default()); + return Ok(RunLayer::default()); }; - let mut layer = parse_run_config(&config.source)?; - resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; - Ok(layer) + let mut document: toml::Table = config + .source + .parse() + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; + let mut run = document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))? + .unwrap_or_default(); + resolve_manifest_dockerfile(&mut run, Path::new(&config.path), &workflow.files)?; + Ok(run) } -fn parse_manifest_config(config: &types::ManifestConfig) -> Result { - let Some(source) = config.source.as_deref() else { - return Ok(SettingsLayer::default()); - }; - parse_settings_layer(source).map_err(|err| anyhow!("Failed to parse settings file: {err}")) -} - -fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> SettingsLayer { +fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettingsOverrides { let Some(args) = args else { - return SettingsLayer::default(); + return ManifestSettingsOverrides::default(); }; let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer { @@ -264,11 +290,7 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> SettingsLayer { }) }); - SettingsLayer { - run, - cli, - ..SettingsLayer::default() - } + ManifestSettingsOverrides { run, cli } } fn parse_labels(labels: &[String]) -> HashMap { @@ -279,15 +301,31 @@ fn parse_labels(labels: &[String]) -> HashMap { .collect() } +fn resolve_working_directory(settings: &WorkflowSettings, caller_cwd: &Path) -> PathBuf { + let Some(work_dir) = settings + .run + .working_dir + .as_ref() + .map(InterpString::as_source) + else { + return caller_cwd.to_path_buf(); + }; + let path = PathBuf::from(&work_dir); + if path.is_absolute() { + path + } else { + caller_cwd.join(path) + } +} + fn resolve_manifest_dockerfile( - layer: &mut SettingsLayer, + run: &mut RunLayer, config_path: &Path, files: &HashMap, ) -> Result<()> { - let source = layer - .run + let source = run + .sandbox .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()); @@ -351,16 +389,14 @@ async fn build_preflight_report( )); } - let settings = &prepared.settings; let configured_providers = state.provider_credentials.configured_providers().await; let materialized = materialize_run( - settings.clone(), + prepared.settings.clone(), graph, Catalog::builtin(), &configured_providers, ); - let resolved_run = fabro_config::resolve_run_from_file(&materialized) - .map_err(|errors| anyhow!(fabro_config::render_resolve_errors(&errors)))?; + let resolved_run = materialized.run; let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; let sandbox_provider = resolve_sandbox_provider(&resolved_run)?; @@ -415,9 +451,7 @@ async fn build_preflight_report( } fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec { - let setup_command_count = fabro_config::resolve_run_from_file(&prepared.settings) - .map(|settings| settings.prepare.commands.len()) - .unwrap_or_default(); + let setup_command_count = prepared.settings.run.prepare.commands.len(); let repo_summary = prepared.git.as_ref().map_or_else( || "unknown".to_string(), |git| { @@ -938,20 +972,23 @@ mod tests { } } - fn server_settings_fixture(source: &str) -> SettingsLayer { - let mut layer = - fabro_config::parse_settings_layer(source).expect("v2 fixture should parse"); - layer.ensure_test_auth_methods(); - layer + fn server_settings_fixture(source: &str) -> RunLayer { + let mut document: toml::Table = source.parse().expect("v2 fixture should parse"); + document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .expect("run settings should parse") + .unwrap_or_default() } - fn default_settings_fixture() -> SettingsLayer { - SettingsLayer::test_default() + fn default_settings_fixture() -> RunLayer { + RunLayer::default() } #[test] fn prepare_manifest_preserves_explicit_manifest_dry_run() { - let server_settings = server_settings_fixture( + let server_settings = manifest_run_defaults(Some(&server_settings_fixture( r#" _version = 1 @@ -961,7 +998,7 @@ mode = "dry_run" [server.storage] root = "/srv/fabro" "#, - ); + ))); let mut manifest = minimal_manifest(); manifest.args = Some(types::ManifestArgs { auto_approve: None, @@ -978,17 +1015,14 @@ root = "/srv/fabro" let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); assert_eq!( - fabro_config::resolve_run_from_file(&prepared.settings) - .unwrap() - .execution - .mode, + prepared.settings.run.execution.mode, fabro_types::settings::run::RunMode::DryRun ); } #[test] fn prepare_manifest_prefers_bundled_settings_without_duplication() { - let server_settings = server_settings_fixture( + let server_settings = manifest_run_defaults(Some(&server_settings_fixture( r#" _version = 1 @@ -999,9 +1033,9 @@ root = "/srv/fabro" script = "cli-setup" [server.integrations.github] -app_id = "snapshotted-app-id" +app_id = "fixture-app-id" "#, - ); + ))); let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().config = @@ -1028,7 +1062,7 @@ methods = ["dev-token"] script = "cli-setup" [server.integrations.github] -app_id = "snapshotted-app-id" +app_id = "fixture-app-id" "# .to_string(), ), @@ -1036,31 +1070,24 @@ app_id = "snapshotted-app-id" }); let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); - let resolved_run = fabro_config::resolve_run_from_file(&prepared.settings).unwrap(); - let resolved_server = fabro_config::resolve_server_from_file(&prepared.settings).unwrap(); + let settings_json = serde_json::to_value(&prepared.settings).unwrap(); // v2 merge matrix: run.prepare.steps replaces the whole list across // layers, so the higher-precedence workflow layer wins over cli. - assert_eq!(resolved_run.prepare.commands, vec![ + assert_eq!(prepared.settings.run.prepare.commands, vec![ "workflow-setup".to_string() ]); - assert_eq!( - resolved_server - .integrations - .github - .app_id - .as_ref() - .map(fabro_types::settings::InterpString::as_source) - .as_deref(), - Some("snapshotted-app-id") - ); - assert_eq!(resolved_server.storage.root.as_source(), "/srv/fabro"); + assert!(settings_json.pointer("/server").is_none()); } #[tokio::test] async fn invalid_preflight_returns_diagnostics_without_runtime_checks() { let state = crate::server::create_app_state(); - let prepared = prepare_manifest(&default_settings_fixture(), &invalid_manifest()).unwrap(); + let prepared = prepare_manifest( + &manifest_run_defaults(Some(&default_settings_fixture())), + &invalid_manifest(), + ) + .unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(validated.has_errors()); @@ -1095,7 +1122,11 @@ enabled = true type_: types::ManifestConfigType::Project, }); - let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); + let prepared = prepare_manifest( + &manifest_run_defaults(Some(&default_settings_fixture())), + &manifest, + ) + .unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(!validated.has_errors()); @@ -1132,7 +1163,11 @@ provider = "daytona" type_: types::ManifestConfigType::Project, }); - let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); + let prepared = prepare_manifest( + &manifest_run_defaults(Some(&default_settings_fixture())), + &manifest, + ) + .unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); let (response, _ok) = run_preflight(state.as_ref(), &prepared, &validated) diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index f7925001b..390c8ab3d 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -6,16 +6,17 @@ use std::time::Duration; use anyhow::Context; use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; -use fabro_config::user::{apply_storage_dir_override, load_settings_config}; -use fabro_config::{ServerSettings, Storage}; +use fabro_config::{ + RunLayer, RunModelLayer, RunSandboxLayer, ServerLayer, ServerWebLayer, Storage, + load_config_file, load_server_runtime_settings, +}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_sandbox::SandboxProvider; -use fabro_types::settings::server::{ - GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy, -}; +use fabro_types::ServerSettings; +use fabro_types::settings::server::{GithubIntegrationStrategy, WebhookStrategy}; use fabro_types::settings::{ - Combine, GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, - ServerNamespace, SettingsLayer, + GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, + ServerNamespace, }; use fabro_util::terminal::Styles; use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey}; @@ -32,8 +33,9 @@ use crate::canonical_origin::resolve_canonical_origin; use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV}; use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config}; use crate::server::{ - AppState, AppStateConfig, RouterOptions, build_app_state, build_router_with_options, - reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, + AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state, + build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, + spawn_scheduler, }; use crate::server_secrets::{ServerSecrets, process_env_snapshot}; use crate::startup::resolve_startup; @@ -158,40 +160,30 @@ pub struct ServeArgs { pub watch_web: bool, } -fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLayer { +fn serve_overrides(args: &ServeArgs) -> (Option, Option) { use fabro_types::settings::interp::InterpString; - use fabro_types::settings::run::{RunLayer, RunModelLayer, RunSandboxLayer}; - use fabro_types::settings::server::{ServerLayer, ServerWebLayer}; - let mut settings = base.clone(); + let mut run = RunLayer::default(); + let mut server = ServerLayer::default(); if args.web || args.no_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 { - 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 { - 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 { - 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()); } - settings -} - -fn apply_runtime_settings( - base: &SettingsLayer, - args: &ServeArgs, - data_dir: &Path, -) -> SettingsLayer { - apply_storage_dir_override(apply_serve_overrides(base, args), Some(data_dir)) + ( + (run != RunLayer::default()).then_some(run), + (server != ServerLayer::default()).then_some(server), + ) } async fn resolve_github_webhook_ip_allowlist( @@ -468,53 +460,24 @@ where } } -fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result { - ServerSettings::from_layer(file) - .map(|settings| settings.server) - .map_err(anyhow::Error::from) -} - pub fn resolve_runtime_server_settings_for_start( args: &ServeArgs, data_dir: &Path, ) -> anyhow::Result { - let disk_settings = load_settings_config(args.config.as_deref())?; - let effective_settings = apply_runtime_settings(&disk_settings, args, data_dir); - resolve_server_settings(&effective_settings) + let (run_overrides, server_overrides) = serve_overrides(args); + let mut resolved = + load_server_runtime_settings(args.config.as_deref(), run_overrides, server_overrides)?; + resolved.server_settings = resolved.server_settings.with_storage_override(data_dir); + Ok(resolved.server_settings.server) } -pub fn resolve_bind_request_from_settings( - settings: &SettingsLayer, +pub fn resolve_bind_request_from_server_settings( + settings: &ServerSettings, explicit_bind: Option<&str>, ) -> anyhow::Result { - let effective_settings = match explicit_bind.map(bind::parse_bind).transpose()? { - Some(BindRequest::TcpHost(host)) => return Ok(BindRequest::TcpHost(host)), - Some(bind) => bind_override_layer(bind).combine(settings.clone()), - None => settings.clone(), - }; - let resolved = resolve_server_settings(&effective_settings)?; - resolved_bind_request(&resolved) -} - -fn bind_override_layer(bind: BindRequest) -> SettingsLayer { - let listen = match bind { - BindRequest::Unix(path) => ServerListenLayer::Unix { - path: Some(InterpString::parse(&path.display().to_string())), - }, - BindRequest::Tcp(address) => ServerListenLayer::Tcp { - address: Some(InterpString::parse(&address.to_string())), - }, - BindRequest::TcpHost(_) => { - unreachable!("host-only bind requests are handled before building a settings override") - } - }; - - SettingsLayer { - server: Some(ServerLayer { - listen: Some(listen), - ..ServerLayer::default() - }), - ..SettingsLayer::default() + match explicit_bind.map(bind::parse_bind).transpose()? { + Some(bind) => Ok(bind), + None => resolved_bind_request(&settings.server), } } @@ -614,8 +577,14 @@ where #[cfg(debug_assertions)] let watch_web = args.watch_web; let config_path = args.config.clone(); - let disk_settings = load_settings_config(config_path.as_deref())?; - let disk_server_settings = resolve_server_settings(&disk_settings)?; + let disk_document: toml::Table = load_config_file(config_path.as_deref(), "settings.toml")?; + let (run_overrides, server_overrides) = serve_overrides(&args); + let mut runtime_settings = load_server_runtime_settings( + config_path.as_deref(), + run_overrides.clone(), + server_overrides.clone(), + )?; + let disk_server_settings = runtime_settings.server_settings.server.clone(); let data_dir = match storage_dir_override { Some(path) => path, None => resolve_interp_path(&disk_server_settings.storage.root)?, @@ -623,18 +592,26 @@ where let storage = Storage::new(&data_dir); let vault_path = storage.secrets_path(); let server_env_path = storage.runtime_directory().env_path(); - // Shared config for live reloading - let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir); - let resolved_server_settings = resolve_server_settings(&effective_settings)?; + runtime_settings.server_settings = runtime_settings + .server_settings + .with_storage_override(&data_dir); + let resolved_app_settings = ResolvedAppStateSettings { + server_settings: runtime_settings.server_settings, + manifest_run_defaults: runtime_settings.manifest_run_defaults, + manifest_run_settings: runtime_settings.manifest_run_settings, + }; + let resolved_server_settings = resolved_app_settings.server_settings.server.clone(); let (auth_mode, server_secrets) = resolve_startup( &server_env_path, process_env_snapshot(), &resolved_server_settings, )?; let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some(); - let bind_request = - resolve_bind_request_from_settings(&effective_settings, args.bind.as_deref())?; - let shared_settings = Arc::new(RwLock::new(effective_settings)); + let bind_request = resolve_bind_request_from_server_settings( + &resolved_app_settings.server_settings, + args.bind.as_deref(), + )?; + let shared_settings = Arc::new(RwLock::new(disk_document)); std::fs::create_dir_all(&data_dir) .with_context(|| format!("creating data directory {}", data_dir.display()))?; let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs; @@ -664,7 +641,7 @@ where let env_lookup: EnvLookup = Arc::new(|name| std::env::var(name).ok()); resolve_canonical_origin(&resolved_server_settings, &env_lookup).map_err(anyhow::Error::msg)?; let state = build_app_state(AppStateConfig { - settings: Arc::clone(&shared_settings), + resolved_settings: resolved_app_settings, registry_factory_override: None, max_concurrent_runs, store, @@ -738,31 +715,54 @@ where // Spawn config polling task let state_for_poll = Arc::clone(&state); + let shared_settings_for_poll = Arc::clone(&shared_settings); let config_path_for_poll = config_path.clone(); - let args_for_poll = args.clone(); + let run_overrides_for_poll = run_overrides.clone(); + let server_overrides_for_poll = server_overrides.clone(); let data_dir_for_poll = data_dir.clone(); tokio::spawn(async move { let mut interval = interval(Duration::from_secs(5)); interval.tick().await; // skip first immediate tick loop { interval.tick().await; - match load_settings_config(config_path_for_poll.as_deref()) { + match load_config_file::(config_path_for_poll.as_deref(), "settings.toml") + { Ok(new_disk_settings) => { - let effective = apply_runtime_settings( - &new_disk_settings, - &args_for_poll, - &data_dir_for_poll, - ); let changed = { - let cfg = state_for_poll - .settings + let cfg = shared_settings_for_poll .read() .expect("config lock poisoned"); - *cfg != effective + *cfg != new_disk_settings }; if changed { - match state_for_poll.replace_settings(effective) { - Ok(()) => info!("Server config reloaded"), + let resolved = load_server_runtime_settings( + config_path_for_poll.as_deref(), + run_overrides_for_poll.clone(), + server_overrides_for_poll.clone(), + ) + .map(|mut resolved| { + resolved.server_settings = resolved + .server_settings + .with_storage_override(&data_dir_for_poll); + ResolvedAppStateSettings { + server_settings: resolved.server_settings, + manifest_run_defaults: resolved.manifest_run_defaults, + manifest_run_settings: resolved.manifest_run_settings, + } + }); + match resolved { + Ok(resolved) => match state_for_poll.replace_runtime_settings(resolved) + { + Ok(()) => { + *shared_settings_for_poll + .write() + .expect("config lock poisoned") = new_disk_settings; + info!("Server config reloaded"); + } + Err(err) => { + warn!(error = %err, "Rejected reloaded server config, keeping previous"); + } + }, Err(err) => { warn!(error = %err, "Rejected reloaded server config, keeping previous"); } @@ -1049,58 +1049,90 @@ mod tests { use std::time::Duration; use fabro_config::bind::{Bind, BindRequest}; - use fabro_config::parse_settings_layer; - use fabro_types::settings::SettingsLayer; + use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder}; + use fabro_types::ServerSettings; use fabro_types::settings::interp::InterpString; use fabro_types::settings::server::ObjectStoreSettings; use fabro_util::Home; use super::{ - GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings, - bind_tcp_host_with_fallback, build_local_object_store_with_preference, - build_object_store_from_settings_with_lookup, build_slatedb_store, - resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist, - resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, server_bind_title, - server_title, + GitHubMetaResolver, ServeArgs, ServerTitlePhase, bind_tcp_host_with_fallback, + build_local_object_store_with_preference, build_object_store_from_settings_with_lookup, + build_slatedb_store, resolve_bind_request_from_server_settings, + resolve_github_webhook_ip_allowlist, resolve_startup_github_webhook_ip_allowlist, + serve_overrides, server_bind_title, server_title, }; - use crate::server::create_app_state_with_options; + use crate::server::ResolvedAppStateSettings; - fn parse_settings(source: &str) -> SettingsLayer { - let mut layer = parse_settings_layer(source).expect("v2 fixture should parse"); - layer.ensure_test_auth_methods(); - layer + fn manifest_run_defaults(source: &str) -> fabro_config::RunLayer { + let mut document: toml::Table = source.parse().expect("v2 fixture should parse"); + document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .expect("run settings should parse") + .unwrap_or_default() + } + + fn server_settings(source: &str) -> ServerSettings { + let mut document: toml::Table = source.parse().expect("v2 fixture should parse"); + let server = document + .entry("server") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .expect("[server] should stay a table in test fixtures"); + let auth = server + .entry("auth") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .expect("[server.auth] should stay a table in test fixtures"); + auth.entry("methods").or_insert_with(|| { + toml::Value::Array(vec![toml::Value::String("dev-token".to_string())]) + }); + ServerSettingsBuilder::from_toml( + &toml::to_string(&document).expect("fixture should serialize"), + ) + .expect("settings should resolve") + } + + fn resolved_runtime_settings(source: &str) -> ResolvedAppStateSettings { + let manifest_run_defaults = manifest_run_defaults(source); + ResolvedAppStateSettings { + manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults) + .map_err(|err| err.to_string()), + manifest_run_defaults, + server_settings: server_settings(source), + } } #[test] - fn apply_runtime_settings_preserves_storage_dir() { - let base = SettingsLayer::default(); - let args = ServeArgs { - bind: None, - model: None, - provider: None, - sandbox: None, - web: false, - no_web: false, - max_concurrent_runs: None, - config: None, - #[cfg(debug_assertions)] - watch_web: false, + fn runtime_server_settings_preserve_storage_dir_override() { + let mut resolved = resolved_runtime_settings("_version = 1\n"); + resolved.server_settings = resolved + .server_settings + .with_storage_override(&PathBuf::from("/srv/fabro-storage")); + + assert_eq!( + resolved.server_settings.server.storage.root.as_source(), + "/srv/fabro-storage" + ); + let fabro_types::settings::ObjectStoreSettings::Local { root } = + &resolved.server_settings.server.artifacts.store + else { + panic!("artifacts store should stay local"); }; - - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro-storage")); - - let storage_root = resolved - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(fabro_types::settings::InterpString::as_source); - assert_eq!(storage_root.as_deref(), Some("/srv/fabro-storage")); + assert_eq!(root.as_source(), "/srv/fabro-storage/objects/artifacts"); + let fabro_types::settings::ObjectStoreSettings::Local { root } = + &resolved.server_settings.server.slatedb.store + else { + panic!("slatedb store should stay local"); + }; + assert_eq!(root.as_source(), "/srv/fabro-storage/objects/slatedb"); } #[test] - fn app_state_server_settings_use_effective_runtime_layer_storage_override() { - let base = parse_settings( + fn runtime_server_settings_keep_disk_defaults_out_of_manifest_defaults() { + let mut resolved = resolved_runtime_settings( r#" _version = 1 @@ -1108,42 +1140,23 @@ _version = 1 root = "/srv/from-disk" "#, ); - let args = ServeArgs { - bind: None, - model: None, - provider: None, - sandbox: None, - web: false, - no_web: false, - max_concurrent_runs: None, - config: None, - #[cfg(debug_assertions)] - watch_web: false, - }; - - let effective = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/from-runtime")); - let state = create_app_state_with_options(effective, 5); + resolved.server_settings = resolved + .server_settings + .with_storage_override(&PathBuf::from("/srv/from-runtime")); assert_eq!( - state.server_settings().server.storage.root.as_source(), + resolved.server_settings.server.storage.root.as_source(), "/srv/from-runtime" ); assert_eq!( - state.server_storage_dir(), - PathBuf::from("/srv/from-runtime") + resolved.manifest_run_defaults, + fabro_config::RunLayer::default(), + "manifest defaults should stay free of server-only overrides" ); } #[test] fn apply_runtime_settings_enables_web_from_cli_flag() { - let base = parse_settings( - r" -_version = 1 - -[server.web] -enabled = false -", - ); let args = ServeArgs { bind: None, model: None, @@ -1157,11 +1170,10 @@ enabled = false watch_web: false, }; - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro")); + let (_, server) = serve_overrides(&args); assert_eq!( - resolved - .server + server .as_ref() .and_then(|server| server.web.as_ref()) .and_then(|web| web.enabled), @@ -1171,7 +1183,6 @@ enabled = false #[test] fn apply_runtime_settings_disables_web_from_cli_flag() { - let base = SettingsLayer::default(); let args = ServeArgs { bind: None, model: None, @@ -1185,11 +1196,10 @@ enabled = false watch_web: false, }; - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro")); + let (_, server) = serve_overrides(&args); assert_eq!( - resolved - .server + server .as_ref() .and_then(|server| server.web.as_ref()) .and_then(|web| web.enabled), @@ -1198,16 +1208,18 @@ enabled = false } #[test] - fn resolve_bind_request_from_settings_defaults_to_socket_when_listen_is_absent() { + fn resolve_bind_request_from_server_settings_defaults_to_socket_when_listen_is_absent() { let bind = - resolve_bind_request_from_settings(&SettingsLayer::test_default(), None).expect("bind"); + resolve_bind_request_from_server_settings(&server_settings("_version = 1\n"), None) + .expect("bind"); assert_eq!(bind, BindRequest::Unix(Home::from_env().socket_path())); } #[test] - fn resolve_bind_request_from_settings_uses_configured_tcp_when_no_explicit_bind_is_given() { - let settings = parse_settings( + fn resolve_bind_request_from_server_settings_uses_configured_tcp_when_no_explicit_bind_is_given() + { + let settings = server_settings( r#" _version = 1 @@ -1217,14 +1229,14 @@ address = "127.0.0.1:0" "#, ); - let bind = resolve_bind_request_from_settings(&settings, None).expect("bind"); + let bind = resolve_bind_request_from_server_settings(&settings, None).expect("bind"); assert_eq!(bind, BindRequest::Tcp("127.0.0.1:0".parse().unwrap())); } #[test] - fn resolve_bind_request_from_settings_prefers_explicit_bind_over_config() { - let settings = parse_settings( + fn resolve_bind_request_from_server_settings_prefers_explicit_bind_over_config() { + let settings = server_settings( r#" _version = 1 @@ -1234,24 +1246,25 @@ address = "127.0.0.1:32276" "#, ); - let bind = - resolve_bind_request_from_settings(&settings, Some("/tmp/fabro.sock")).expect("bind"); + let bind = resolve_bind_request_from_server_settings(&settings, Some("/tmp/fabro.sock")) + .expect("bind"); assert_eq!(bind, BindRequest::Unix(PathBuf::from("/tmp/fabro.sock"))); } #[test] - fn resolve_bind_request_from_settings_preserves_host_only_cli_bind() { - let settings = SettingsLayer::test_default(); + fn resolve_bind_request_from_server_settings_preserves_host_only_cli_bind() { + let settings = server_settings("_version = 1\n"); - let bind = resolve_bind_request_from_settings(&settings, Some("127.0.0.1")).expect("bind"); + let bind = + resolve_bind_request_from_server_settings(&settings, Some("127.0.0.1")).expect("bind"); assert_eq!(bind, BindRequest::TcpHost("127.0.0.1".parse().unwrap())); } #[test] fn web_enabled_stays_enabled_without_github_app_mode() { - let base = parse_settings( + let base = server_settings( r#" _version = 1 @@ -1263,7 +1276,7 @@ strategy = "token" "#, ); - let resolved = resolve_server_settings(&base).expect("settings should resolve"); + let resolved = base.server; assert!(resolved.web.enabled); } @@ -1317,7 +1330,7 @@ strategy = "token" fn build_slatedb_store_uses_configured_local_root() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("custom-slatedb"); - let settings = parse_settings(&format!( + let resolved = server_settings(&format!( r#" _version = 1 @@ -1325,9 +1338,8 @@ _version = 1 root = "{}" "#, root.display() - )); - - let resolved = resolve_server_settings(&settings).expect("settings should resolve"); + )) + .server; let (_object_store, prefix, flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1339,16 +1351,15 @@ root = "{}" #[test] fn build_slatedb_store_returns_disk_cache_when_enabled() { - let settings = parse_settings( + let resolved = server_settings( r" _version = 1 [server.slatedb] disk_cache = true ", - ); - - let resolved = resolve_server_settings(&settings).expect("settings should resolve"); + ) + .server; let (_object_store, _prefix, _flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1472,7 +1483,7 @@ disk_cache = true #[tokio::test] async fn resolve_github_webhook_ip_allowlist_propagates_resolution_errors() { - let settings = resolve_server_settings(&parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1487,8 +1498,8 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )) - .expect("settings should resolve"); + ) + .server; let cache_dir = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1509,7 +1520,7 @@ entries = ["github_meta_hooks"] #[tokio::test] async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() { - let settings = resolve_server_settings(&parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1524,8 +1535,8 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )) - .expect("settings should resolve"); + ) + .server; let cache_dir = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 468919707..41517017e 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -40,7 +40,7 @@ pub use fabro_api::types::{ }; use fabro_auth::{configured_providers_from_process_env, parse_credential_secret}; use fabro_config::daemon::ServerDaemon; -use fabro_config::{ServerSettings, Storage, envfile}; +use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, Storage, envfile}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -65,15 +65,12 @@ use fabro_store::{ #[cfg(test)] use fabro_types::BlockedReason; use fabro_types::settings::run::RunMode; -use fabro_types::settings::server::{ - GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, - ServerLayer, -}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::server::{GithubIntegrationSettings, GithubIntegrationStrategy}; +use fabro_types::settings::{InterpString, RunNamespace}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, PullRequestRecord, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, - RunServerProvenance, RunSubjectProvenance, + RunServerProvenance, RunSubjectProvenance, ServerSettings, }; use fabro_util::redact::redact_jsonl_line; use fabro_util::text::strip_goal_decoration; @@ -554,7 +551,8 @@ pub struct AppState { pub(crate) vault: Arc>, pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, - pub(crate) settings: Arc>, + manifest_run_defaults: RwLock>, + manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, pub(crate) github_api_base_url: String, @@ -566,7 +564,7 @@ pub struct AppState { } pub(crate) struct AppStateConfig { - pub(crate) settings: Arc>, + pub(crate) resolved_settings: ResolvedAppStateSettings, pub(crate) registry_factory_override: Option>, pub(crate) max_concurrent_runs: usize, pub(crate) store: Arc, @@ -578,6 +576,13 @@ pub(crate) struct AppStateConfig { pub(crate) http_client: Option, } +#[derive(Clone)] +pub(crate) struct ResolvedAppStateSettings { + pub(crate) server_settings: ServerSettings, + pub(crate) manifest_run_defaults: RunLayer, + pub(crate) manifest_run_settings: std::result::Result, +} + fn nonzero_i64(value: i64) -> Option { (value != 0).then_some(value) } @@ -622,6 +627,15 @@ fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelU } impl AppState { + pub(crate) fn manifest_run_defaults(&self) -> Arc { + Arc::clone( + &self + .manifest_run_defaults + .read() + .expect("manifest run defaults lock poisoned"), + ) + } + pub(crate) fn server_settings(&self) -> Arc { Arc::clone( &self @@ -631,6 +645,13 @@ impl AppState { ) } + pub(crate) fn manifest_run_settings(&self) -> std::result::Result { + self.manifest_run_settings + .read() + .expect("manifest run settings lock poisoned") + .clone() + } + fn http_client(&self) -> Result { match &self.http_client { Some(client) => Ok(client.clone()), @@ -743,15 +764,32 @@ impl AppState { self.shutting_down.load(Ordering::Relaxed) } - pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { - let resolved = Arc::new(ServerSettings::from_layer(&settings)?); - resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; + pub(crate) fn replace_runtime_settings( + &self, + resolved_settings: ResolvedAppStateSettings, + ) -> anyhow::Result<()> { + let ResolvedAppStateSettings { + server_settings, + manifest_run_defaults, + manifest_run_settings, + } = resolved_settings; + let server_settings = Arc::new(server_settings); + let manifest_run_defaults = Arc::new(manifest_run_defaults); + resolve_canonical_origin(&server_settings.server, &self.env_lookup) + .map_err(anyhow::Error::msg)?; - *self.settings.write().expect("settings lock poisoned") = settings; + *self + .manifest_run_defaults + .write() + .expect("manifest run defaults lock poisoned") = manifest_run_defaults; + *self + .manifest_run_settings + .write() + .expect("manifest run settings lock poisoned") = manifest_run_settings; *self .server_settings .write() - .expect("server settings lock poisoned") = resolved; + .expect("server settings lock poisoned") = server_settings; Ok(()) } } @@ -1231,11 +1269,8 @@ async fn get_system_info( _auth: AuthenticatedService, State(state): State>, ) -> Response { - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let manifest_run_settings = state.manifest_run_settings(); + let server_settings = state.server_settings(); let (total_runs, active_runs) = { let runs = state.runs.lock().expect("runs lock poisoned"); let active = runs @@ -1268,16 +1303,23 @@ async fn get_system_info( total: Some(to_i64(total_runs)), active: Some(to_i64(active_runs)), }), - sandbox_provider: Some(system_sandbox_provider(&settings)), - features: Some(system_features(&settings)), + sandbox_provider: Some(system_sandbox_provider(&manifest_run_settings)), + features: Some(system_features( + server_settings.as_ref(), + &manifest_run_settings, + )), }; (StatusCode::OK, Json(response)).into_response() } -fn system_features(settings: &SettingsLayer) -> SystemFeatures { - let session_sandboxes = - fabro_config::resolve_features_from_file(settings).is_ok_and(|s| s.session_sandboxes); - let retros = fabro_config::resolve_run_from_file(settings).is_ok_and(|s| s.execution.retros); +fn system_features( + server_settings: &ServerSettings, + manifest_run_settings: &std::result::Result, +) -> SystemFeatures { + let session_sandboxes = server_settings.features.session_sandboxes; + let retros = manifest_run_settings + .as_ref() + .is_ok_and(|settings| settings.execution.retros); SystemFeatures { session_sandboxes: Some(session_sandboxes), retros: Some(retros), @@ -1569,10 +1611,30 @@ fn build_prune_plan( }) } -fn system_sandbox_provider(settings: &SettingsLayer) -> String { - fabro_config::resolve_run_from_file(settings).map_or_else( +fn resolve_manifest_run_settings( + manifest_run_defaults: &RunLayer, +) -> std::result::Result { + RunSettingsBuilder::from_run_layer(manifest_run_defaults).map_err(|err| err.to_string()) +} + +fn default_test_server_settings() -> ServerSettings { + ServerSettingsBuilder::from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ) + .expect("default test server settings should resolve") +} + +fn system_sandbox_provider( + manifest_run_settings: &std::result::Result, +) -> String { + manifest_run_settings.as_ref().map_or_else( |_| SandboxProvider::default().to_string(), - |settings| settings.sandbox.provider, + |settings| settings.sandbox.provider.clone(), ) } @@ -2265,66 +2327,143 @@ async fn get_run_billing( /// Create an `AppState` with default settings. pub fn create_app_state() -> Arc { - create_app_state_with_options(SettingsLayer::default(), 5) + create_app_state_with_options(default_test_server_settings(), RunLayer::default(), 5) } #[doc(hidden)] pub fn create_app_state_with_registry_factory( registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - create_app_state_with_options_and_registry_factory( - SettingsLayer::default(), - 5, + create_app_state_with_settings_and_registry_factory( + default_test_server_settings(), + RunLayer::default(), registry_factory_override, ) } #[doc(hidden)] pub fn create_app_state_with_settings_and_registry_factory( - settings: SettingsLayer, + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - create_app_state_with_options_and_registry_factory(settings, 5, registry_factory_override) + create_app_state_with_options_and_registry_factory( + server_settings, + manifest_run_defaults, + 5, + registry_factory_override, + ) } #[doc(hidden)] pub fn create_app_state_with_options_and_registry_factory( - settings: SettingsLayer, + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, max_concurrent_runs: usize, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - let env_lookup = default_env_lookup(); - let settings = Arc::new(RwLock::new(settings)); - ensure_test_auth_methods(&settings); - let mut config = default_test_app_state_config(settings, max_concurrent_runs, env_lookup); - config.registry_factory_override = Some(Box::new(registry_factory_override)); - build_app_state(config).expect("test app state should build") + create_app_state_with_runtime_settings_and_options_and_registry_factory( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + registry_factory_override, + ) } /// Create an `AppState` with the given settings and concurrency limit. pub fn create_app_state_with_options( - settings: SettingsLayer, + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, max_concurrent_runs: usize, ) -> Arc { - let settings = Arc::new(RwLock::new(settings)); - ensure_test_auth_methods(&settings); - let env_lookup = default_env_lookup(); - build_app_state(default_test_app_state_config( - settings, + create_app_state_with_runtime_settings_and_options( + server_settings, + manifest_run_defaults, max_concurrent_runs, - env_lookup, - )) - .expect("test app state should build") + ) +} + +fn resolved_runtime_settings_for_tests( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, +) -> ResolvedAppStateSettings { + ResolvedAppStateSettings { + manifest_run_settings: resolve_manifest_run_settings(&manifest_run_defaults), + manifest_run_defaults, + server_settings, + } } #[doc(hidden)] -pub fn create_app_state_with_env_lookup( - settings: SettingsLayer, +pub fn create_app_state_with_runtime_settings_and_registry_factory( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, +) -> Arc { + create_app_state_with_runtime_settings_and_options_and_registry_factory( + server_settings, + manifest_run_defaults, + 5, + registry_factory_override, + ) +} + +#[doc(hidden)] +pub fn create_app_state_with_runtime_settings_and_options_and_registry_factory( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, + registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, +) -> Arc { + let (store, artifact_store) = test_store_bundle(); + let vault_path = test_secret_store_path(); + let server_env_path = vault_path.with_file_name("server.env"); + let env_lookup = default_env_lookup(); + let mut config = AppStateConfig { + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + manifest_run_defaults, + ), + registry_factory_override: None, + max_concurrent_runs, + store, + artifact_store, + vault_path, + server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), + env_lookup, + github_api_base_url: None, + http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + }; + config.registry_factory_override = Some(Box::new(registry_factory_override)); + build_app_state(config).expect("test app state should build") +} + +/// Create an `AppState` with dense runtime settings and a concurrency limit. +#[doc(hidden)] +pub fn create_app_state_with_runtime_settings_and_options( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, +) -> Arc { + create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + |name| std::env::var(name).ok(), + &HashMap::new(), + ) +} + +#[doc(hidden)] +pub fn create_app_state_with_runtime_settings_and_env_lookup( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, max_concurrent_runs: usize, env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, ) -> Arc { - create_app_state_with_env_lookup_and_server_secret_env( - settings, + create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + server_settings, + manifest_run_defaults, max_concurrent_runs, env_lookup, &HashMap::new(), @@ -2332,22 +2471,65 @@ pub fn create_app_state_with_env_lookup( } #[doc(hidden)] -pub fn create_app_state_with_env_lookup_and_server_secret_env( - settings: SettingsLayer, +pub fn create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, max_concurrent_runs: usize, env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, server_secret_env: &HashMap, ) -> Arc { let (store, artifact_store) = test_store_bundle(); let env_lookup: EnvLookup = Arc::new(env_lookup); - let settings = Arc::new(RwLock::new(settings)); - ensure_test_auth_methods(&settings); - let mut config = default_test_app_state_config(settings, max_concurrent_runs, env_lookup); - config.store = store; - config.artifact_store = artifact_store; - let server_env_path = config.vault_path.with_file_name("server.env"); - config.server_secrets = load_test_server_secrets(server_env_path, server_secret_env.clone()); - build_app_state(config).expect("test app state should build") + let vault_path = test_secret_store_path(); + let server_env_path = vault_path.with_file_name("server.env"); + build_app_state(AppStateConfig { + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + manifest_run_defaults, + ), + registry_factory_override: None, + max_concurrent_runs, + store, + artifact_store, + vault_path, + server_secrets: load_test_server_secrets(server_env_path, server_secret_env.clone()), + env_lookup, + github_api_base_url: None, + http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + }) + .expect("test app state should build") +} + +#[doc(hidden)] +pub fn create_app_state_with_env_lookup( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, + env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, +) -> Arc { + create_app_state_with_runtime_settings_and_env_lookup( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + env_lookup, + ) +} + +#[doc(hidden)] +pub fn create_app_state_with_env_lookup_and_server_secret_env( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, + env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, + server_secret_env: &HashMap, +) -> Arc { + create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + env_lookup, + server_secret_env, + ) } #[cfg(test)] @@ -2355,8 +2537,9 @@ pub fn create_app_state_with_env_lookup_and_server_secret_env( clippy::disallowed_methods, reason = "test helper writes a fixture server.env with sync std::fs::write" )] -pub(crate) fn create_test_app_state_with_session_key( - settings: SettingsLayer, +pub(crate) fn create_test_app_state_with_runtime_settings_and_session_key( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, session_secret: Option<&str>, ) -> Arc { let vault_path = test_secret_store_path(); @@ -2373,10 +2556,11 @@ pub(crate) fn create_test_app_state_with_session_key( } let (store, artifact_store) = test_store_bundle(); let env_lookup = default_env_lookup(); - let settings = Arc::new(RwLock::new(settings)); - ensure_test_auth_methods(&settings); build_app_state(AppStateConfig { - settings, + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + manifest_run_defaults, + ), registry_factory_override: None, max_concurrent_runs: 5, store, @@ -2390,6 +2574,35 @@ pub(crate) fn create_test_app_state_with_session_key( .expect("test app state should build") } +#[cfg(test)] +pub(crate) fn create_test_app_state_with_session_key( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + session_secret: Option<&str>, +) -> Arc { + create_test_app_state_with_runtime_settings_and_session_key( + server_settings, + manifest_run_defaults, + session_secret, + ) +} + +pub fn create_app_state_with_store( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, + store: Arc, + artifact_store: ArtifactStore, +) -> Arc { + create_app_state_with_store_and_runtime_settings( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + store, + artifact_store, + ) +} + fn test_store_bundle() -> (Arc, ArtifactStore) { let object_store: Arc = Arc::new(MemoryObjectStore::new()); let store = Arc::new(fabro_store::Database::new( @@ -2402,74 +2615,32 @@ fn test_store_bundle() -> (Arc, ArtifactStore) { (store, artifact_store) } -fn default_test_app_state_config( - settings: Arc>, +#[doc(hidden)] +pub fn create_app_state_with_store_and_runtime_settings( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, max_concurrent_runs: usize, - env_lookup: EnvLookup, -) -> AppStateConfig { - ensure_test_auth_methods(&settings); - let (store, artifact_store) = test_store_bundle(); + store: Arc, + artifact_store: ArtifactStore, +) -> Arc { let vault_path = test_secret_store_path(); let server_env_path = vault_path.with_file_name("server.env"); - AppStateConfig { - settings, + build_app_state(AppStateConfig { + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + manifest_run_defaults, + ), registry_factory_override: None, max_concurrent_runs, store, artifact_store, vault_path, server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), - env_lookup, + env_lookup: default_env_lookup(), github_api_base_url: None, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - } -} - -fn ensure_test_auth_methods(settings: &Arc>) { - let mut settings = settings.write().expect("test settings lock poisoned"); - if settings - .server - .as_ref() - .and_then(|server| server.auth.as_ref()) - .and_then(|auth| auth.methods.as_ref()) - .is_some() - { - return; - } - let server = settings.server.get_or_insert_with(ServerLayer::default); - let auth = server.auth.get_or_insert_with(ServerAuthLayer::default); - auth.methods = Some(vec![ServerAuthMethod::DevToken]); -} - -pub fn create_app_state_with_store( - settings: Arc>, - max_concurrent_runs: usize, - store: Arc, - artifact_store: ArtifactStore, -) -> Arc { - let env_lookup = default_env_lookup(); - create_app_state_with_store_and_env_lookup( - settings, - max_concurrent_runs, - store, - artifact_store, - &env_lookup, - ) -} - -fn create_app_state_with_store_and_env_lookup( - settings: Arc>, - max_concurrent_runs: usize, - store: Arc, - artifact_store: ArtifactStore, - env_lookup: &EnvLookup, -) -> Arc { - ensure_test_auth_methods(&settings); - let mut config = - default_test_app_state_config(settings, max_concurrent_runs, Arc::clone(env_lookup)); - config.store = store; - config.artifact_store = artifact_store; - build_app_state(config).expect("test app state should build") + }) + .expect("test app state should build") } fn default_env_lookup() -> EnvLookup { @@ -2502,7 +2673,7 @@ fn worker_token_keys_from_server_secrets( pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result> { let AppStateConfig { - settings, + resolved_settings, registry_factory_override, max_concurrent_runs, store, @@ -2520,10 +2691,9 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result req, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; - let prepared = match run_manifest::prepare_manifest( - &state.settings.read().expect("settings lock poisoned"), - &req, - ) { + let manifest_run_defaults = state.manifest_run_defaults(); + let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -4056,10 +4225,8 @@ async fn run_preflight( State(state): State>, Json(req): Json, ) -> Response { - let prepared = match run_manifest::prepare_manifest( - &state.settings.read().expect("settings lock poisoned"), - &req, - ) { + let manifest_run_defaults = state.manifest_run_defaults(); + let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -4085,13 +4252,12 @@ async fn render_graph_from_manifest( State(state): State>, Json(req): Json, ) -> Response { - let prepared = match run_manifest::prepare_manifest( - &state.settings.read().expect("settings lock poisoned"), - &req.manifest, - ) { - Ok(prepared) => prepared, - Err(err) => return ApiError::bad_request(err.to_string()).into_response(), - }; + let manifest_run_defaults = state.manifest_run_defaults(); + let prepared = + match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req.manifest) { + Ok(prepared) => prepared, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; let validated = match run_manifest::validate_prepared_manifest(&prepared) { Ok(validated) => validated, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), @@ -4358,33 +4524,28 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { }; let server_settings = state.server_settings(); let github_settings = &server_settings.server.integrations.github; - let github_app_result = match fabro_config::resolve_run_from_file( - &persisted.run_spec().settings, - ) { - Ok(settings) => { - let required_github_credentials = (settings.execution.mode != RunMode::DryRun - && settings.sandbox.provider == "daytona") - || !github_settings.permissions.is_empty(); - if required_github_credentials { - state.github_credentials(github_settings) - } else if settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some() - { - match state.github_credentials(github_settings) { - Ok(github_app) => Ok(github_app), - Err(err) => { - tracing::warn!( - run_id = %run_id, - error = %err, - "GitHub credentials unavailable; pull request creation will be skipped" - ); - Ok(None) - } + let github_app_result = { + let settings = &persisted.run_spec().settings.run; + let required_github_credentials = (settings.execution.mode != RunMode::DryRun + && settings.sandbox.provider == "daytona") + || !github_settings.permissions.is_empty(); + if required_github_credentials { + state.github_credentials(github_settings) + } else if settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some() { + match state.github_credentials(github_settings) { + Ok(github_app) => Ok(github_app), + Err(err) => { + tracing::warn!( + run_id = %run_id, + error = %err, + "GitHub credentials unavailable; pull request creation will be skipped" + ); + Ok(None) } - } else { - Ok(None) } + } else { + Ok(None) } - Err(_) => Ok(None), }; let github_app = match github_app_result { Ok(github_app) => github_app, @@ -7531,8 +7692,8 @@ mod tests { use axum::body::Body; use axum::http::{Method, Request, header}; use chrono::{Duration as ChronoDuration, Utc}; + use fabro_config::ServerSettingsBuilder; use fabro_config::bind::Bind; - use fabro_config::parse_settings_layer; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; use fabro_llm::Error as LlmError; use fabro_llm::client::Client as LlmClient; @@ -7571,6 +7732,27 @@ mod tests { const WRONG_DEV_TOKEN: &str = "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + fn manifest_run_defaults_from_toml(source: &str) -> fabro_config::RunLayer { + let mut document: toml::Table = source.parse().expect("run defaults should parse"); + document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .expect("run defaults should parse") + .unwrap_or_default() + } + + fn server_settings_from_toml(source: &str) -> ServerSettings { + ServerSettingsBuilder::from_toml(source).expect("server settings should resolve") + } + + fn resolved_runtime_settings_from_toml(source: &str) -> ResolvedAppStateSettings { + resolved_runtime_settings_for_tests( + server_settings_from_toml(source), + manifest_run_defaults_from_toml(source), + ) + } + fn test_app_with() -> Router { let state = create_app_state(); build_router(state, AuthMode::Disabled) @@ -7621,7 +7803,8 @@ mod tests { fn webhook_test_app(auth_mode: AuthMode) -> Router { let secret = TEST_WEBHOOK_SECRET.to_string(); let state = create_app_state_with_env_lookup_and_server_secret_env( - SettingsLayer::default(), + default_test_server_settings(), + RunLayer::default(), 5, |_| None, &HashMap::from([(WEBHOOK_SECRET_ENV.to_string(), secret)]), @@ -7677,7 +7860,11 @@ mod tests { } fn jwt_auth_state() -> Arc { - create_test_app_state_with_session_key(SettingsLayer::default(), Some(TEST_SESSION_SECRET)) + create_test_app_state_with_session_key( + default_test_server_settings(), + RunLayer::default(), + Some(TEST_SESSION_SECRET), + ) } fn jwt_auth_app() -> (Arc, Router) { @@ -7742,8 +7929,8 @@ mod tests { .unwrap() } - fn canonical_origin_settings(url: &str) -> SettingsLayer { - fabro_config::parse_settings_layer(&format!( + fn canonical_origin_settings(url: &str) -> ServerSettings { + server_settings_from_toml(&format!( r#" _version = 1 @@ -7754,7 +7941,6 @@ methods = ["dev-token"] url = "{url}" "# )) - .expect("settings fixture should parse") } #[test] @@ -7762,6 +7948,7 @@ url = "{url}" for invalid in ["", "/relative/path", "ftp://fabro.example.com"] { let state = create_app_state_with_env_lookup( canonical_origin_settings("http://valid.example.com"), + RunLayer::default(), 5, { let invalid = invalid.to_string(); @@ -7770,7 +7957,17 @@ url = "{url}" ); let err = state - .replace_settings(canonical_origin_settings("{{ env.FABRO_WEB_URL }}")) + .replace_runtime_settings(resolved_runtime_settings_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "{{ env.FABRO_WEB_URL }}" +"#, + )) .expect_err("invalid canonical origin should be rejected"); assert!( err.to_string() @@ -7787,7 +7984,7 @@ url = "{url}" #[test] fn replace_settings_updates_layer_and_typed_server_settings() { let state = create_app_state_with_options( - fabro_config::parse_settings_layer( + server_settings_from_toml( r#" _version = 1 @@ -7800,13 +7997,25 @@ url = "http://old.example.com" [server.storage] root = "/srv/old" "#, - ) - .expect("settings fixture should parse"), + ), + manifest_run_defaults_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://old.example.com" + +[server.storage] +root = "/srv/old" +"#, + ), 5, ); - let updated = fabro_config::parse_settings_layer( - r#" + let updated = r#" _version = 1 [server.auth] @@ -7815,14 +8024,15 @@ methods = ["dev-token"] [server.web] url = "http://new.example.com" +[run.execution] +mode = "dry_run" + [server.storage] root = "/srv/new" -"#, - ) - .expect("settings fixture should parse"); +"#; state - .replace_settings(updated) + .replace_runtime_settings(resolved_runtime_settings_from_toml(updated)) .expect("valid settings should replace current state"); assert_eq!(state.canonical_origin().unwrap(), "http://new.example.com"); @@ -7830,24 +8040,161 @@ root = "/srv/new" state.server_settings().server.storage.root.as_source(), "/srv/new" ); + assert_eq!( + state + .manifest_run_settings() + .expect("manifest run settings should resolve") + .execution + .mode, + RunMode::DryRun + ); + let manifest_run_defaults = state.manifest_run_defaults(); + assert_eq!( + manifest_run_defaults + .execution + .as_ref() + .and_then(|execution| execution.mode), + Some(RunMode::DryRun) + ); + } - let layer_root = state - .settings - .read() - .expect("settings lock poisoned") - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source); - assert_eq!(layer_root.as_deref(), Some("/srv/new")); + #[test] + fn replace_settings_caches_invalid_manifest_run_settings_tolerantly() { + let state = create_app_state_with_options( + server_settings_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://old.example.com" +"#, + ), + manifest_run_defaults_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://old.example.com" +"#, + ), + 5, + ); + + let updated = r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://new.example.com" + +[run.sandbox] +provider = "invalid-provider" +"#; + + state + .replace_runtime_settings(resolved_runtime_settings_from_toml(updated)) + .expect("invalid run defaults should not block replace"); + + assert_eq!(state.canonical_origin().unwrap(), "http://new.example.com"); + assert!( + state.manifest_run_settings().is_err(), + "manifest run settings should stay tolerant for invalid defaults" + ); + } + + #[test] + fn system_features_use_dense_server_and_manifest_defaults() { + let source = r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[features] +session_sandboxes = true + +[run.execution] +retros = false +"#; + let server_settings = server_settings_from_toml(source); + let manifest_run_settings = resolve_manifest_run_settings( + &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), + ); + let features = system_features(&server_settings, &manifest_run_settings); + + assert_eq!(features.session_sandboxes, Some(true)); + assert_eq!(features.retros, Some(false)); + } + + #[test] + fn system_features_default_retros_when_manifest_run_settings_do_not_resolve() { + let source = r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[features] +session_sandboxes = true + +[run.sandbox] +provider = "invalid-provider" +"#; + let server_settings = server_settings_from_toml(source); + let manifest_run_settings = resolve_manifest_run_settings( + &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), + ); + let features = system_features(&server_settings, &manifest_run_settings); + + assert_eq!(features.session_sandboxes, Some(true)); + assert_eq!(features.retros, Some(false)); + } + + #[test] + fn system_sandbox_provider_uses_manifest_defaults() { + let source = r#" +_version = 1 + +[run.sandbox] +provider = "daytona" +"#; + let manifest_run_settings = resolve_manifest_run_settings( + &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), + ); + + assert_eq!(system_sandbox_provider(&manifest_run_settings), "daytona"); + } + + #[test] + fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve() { + let source = r#" +_version = 1 + +[run.sandbox] +provider = "invalid-provider" +"#; + let manifest_run_settings = resolve_manifest_run_settings( + &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), + ); + + assert_eq!( + system_sandbox_provider(&manifest_run_settings), + SandboxProvider::default().to_string() + ); } #[tokio::test] async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() { let state = create_app_state(); let app = build_router(Arc::clone(&state), AuthMode::Disabled); - let req = Request::builder() .method("POST") .uri(api("/secrets")) @@ -8240,13 +8587,22 @@ root = "/srv/new" #[test] fn build_app_state_requires_session_secret_for_worker_tokens() { - let settings = Arc::new(RwLock::new(SettingsLayer::default())); - ensure_test_auth_methods(&settings); + let server_settings = server_settings_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ); let (store, artifact_store) = test_store_bundle(); let vault_path = test_secret_store_path(); let server_env_path = vault_path.with_file_name("server.env"); let Err(err) = build_app_state(AppStateConfig { - settings, + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + RunLayer::default(), + ), registry_factory_override: None, max_concurrent_runs: 5, store, @@ -8274,7 +8630,7 @@ root = "/srv/new" ) -> Arc { let dev_token = dev_token.map(str::to_owned); std::fs::create_dir_all(storage_dir).unwrap(); - let settings = fabro_config::parse_settings_layer(&format!( + let source = format!( r#" _version = 1 @@ -8293,8 +8649,7 @@ allowed_usernames = ["octocat"] .map(|method| format!("\"{method}\"")) .collect::>() .join(", ") - )) - .unwrap(); + ); let runtime_directory = Storage::new(storage_dir).runtime_directory(); ServerDaemon::new( std::process::id(), @@ -8308,7 +8663,8 @@ allowed_usernames = ["octocat"] .map(|token| HashMap::from([("FABRO_DEV_TOKEN".to_string(), token)])) .unwrap_or_default(); create_app_state_with_env_lookup_and_server_secret_env( - settings, + server_settings_from_toml(&source), + manifest_run_defaults_from_toml(&source), 5, |_| None, &server_secret_env, @@ -8534,27 +8890,45 @@ allowed_usernames = ["octocat"] run_store.append_event(&payload).await.unwrap(); } - fn github_token_settings() -> SettingsLayer { - parse_settings_layer( + fn github_token_settings() -> ServerSettings { + ServerSettingsBuilder::from_toml( r#" _version = 1 +[server.auth] +methods = ["dev-token"] + [server.integrations.github] strategy = "token" "#, ) - .expect("github token settings fixture should parse") + .expect("github token settings fixture should resolve") } fn create_github_token_app_state( token: Option<&str>, github_api_base_url: Option, ) -> Arc { - let settings = Arc::new(RwLock::new(github_token_settings())); - ensure_test_auth_methods(&settings); - let env_lookup: EnvLookup = Arc::new(|_| None); - let mut config = default_test_app_state_config(settings, 5, env_lookup); - config.github_api_base_url = github_api_base_url; + let (store, artifact_store) = test_store_bundle(); + let vault_path = test_secret_store_path(); + let server_env_path = vault_path.with_file_name("server.env"); + let config = AppStateConfig { + resolved_settings: resolved_runtime_settings_for_tests( + github_token_settings(), + RunLayer::default(), + ), + registry_factory_override: None, + max_concurrent_runs: 5, + store, + artifact_store, + vault_path, + server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), + env_lookup: Arc::new(|_| None), + github_api_base_url, + http_client: Some( + fabro_http::test_http_client().expect("test HTTP client should build"), + ), + }; let state = build_app_state(config).expect("test app state should build"); if let Some(token) = token { state @@ -8689,7 +9063,7 @@ strategy = "token" ); let run_spec = RunSpec { run_id, - settings: SettingsLayer::default(), + settings: fabro_types::WorkflowSettings::default(), graph, workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -8781,7 +9155,12 @@ strategy = "token" #[tokio::test] async fn test_model_alias_returns_canonical_model_id() { - let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, |_| None); + let state = create_app_state_with_env_lookup( + default_test_server_settings(), + RunLayer::default(), + 5, + |_| None, + ); let app = build_router(state, AuthMode::Disabled); let req = Request::builder() @@ -8799,7 +9178,12 @@ strategy = "token" #[tokio::test] async fn test_model_invalid_mode_returns_400() { - let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, |_| None); + let state = create_app_state_with_env_lookup( + default_test_server_settings(), + RunLayer::default(), + 5, + |_| None, + ); let app = build_router(state, AuthMode::Disabled); let req = Request::builder() @@ -8875,24 +9259,28 @@ strategy = "token" #[tokio::test] async fn auth_login_github_redirects_to_github() { - let settings: SettingsLayer = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 +[server.auth] +methods = ["github"] + [server.web] enabled = true url = "http://localhost:3000" +[server.auth.github] +allowed_usernames = ["octocat"] + [server.integrations.github] app_id = "123" client_id = "Iv1.testclient" slug = "fabro" -"#, - ) - .expect("fixture should parse"); +"#; let app = build_router( create_test_app_state_with_session_key( - settings, + server_settings_from_toml(source), + manifest_run_defaults_from_toml(source), Some("github-redirect-test-key-0123456789"), ), AuthMode::Enabled(ConfiguredAuth { @@ -10117,7 +10505,8 @@ slug = "fabro" "fabro_dev_abababababababababababababababababababababababababababababababab"; let state = create_test_app_state_with_session_key( - SettingsLayer::default(), + default_test_server_settings(), + RunLayer::default(), Some("server-test-session-key-0123456789"), ); let app = build_router( @@ -11530,10 +11919,12 @@ slug = "fabro" #[tokio::test] async fn start_run_persists_full_settings_snapshot() { - let settings: SettingsLayer = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 +[server.auth] +methods = ["dev-token"] + [run.execution] mode = "dry_run" @@ -11567,10 +11958,12 @@ url = "http://api.example.test" [server.logging] level = "debug" -"#, - ) - .expect("fixture should parse"); - let state = create_app_state_with_options(settings, 5); +"#; + let state = create_app_state_with_options( + server_settings_from_toml(source), + manifest_run_defaults_from_toml(source), + 5, + ); let app = build_router(Arc::clone(&state), AuthMode::Disabled); let req = Request::builder() @@ -11600,12 +11993,12 @@ level = "debug" .unwrap() .spec .expect("run spec should exist"); - let resolved_run = fabro_config::resolve_run_from_file(&run_spec.settings).unwrap(); + let resolved_run = &run_spec.settings.run; // Verify a sampling of the persisted v2 settings, including inherited // run execution mode from server settings. assert_eq!( - match resolved_run.goal { + match &resolved_run.goal { Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()), _ => None, } @@ -11630,14 +12023,8 @@ level = "debug" // Server-operational fields (auth, integrations, etc.) deliberately // do not flow into the run's persisted settings — they live on the // server and are read via AppState::server_settings(). - assert!(run_spec.settings.server.as_ref().is_none_or(|server| { - server - .integrations - .as_ref() - .and_then(|integrations| integrations.github.as_ref()) - .and_then(|github| github.app_id.as_ref()) - .is_none() - })); + let settings_json = serde_json::to_value(&run_spec.settings).unwrap(); + assert!(settings_json.pointer("/server").is_none()); } #[tokio::test] @@ -12129,21 +12516,23 @@ level = "debug" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancel_during_startup_persists_cancelled_reason() { - let settings: SettingsLayer = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 +[server.auth] +methods = ["dev-token"] + [[run.prepare.steps]] script = "sleep 5" [run.prepare] timeout = "30s" -"#, - ) - .expect("fixture should parse"); - let state = create_app_state_with_settings_and_registry_factory(settings, |interviewer| { - fabro_workflow::handler::default_registry(interviewer, || None) - }); +"#; + let state = create_app_state_with_settings_and_registry_factory( + server_settings_from_toml(source), + manifest_run_defaults_from_toml(source), + |interviewer| fabro_workflow::handler::default_registry(interviewer, || None), + ); let app = build_router(Arc::clone(&state), AuthMode::Disabled); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; @@ -12289,7 +12678,8 @@ timeout = "30s" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrency_limit_respected() { - let state = create_app_state_with_options(SettingsLayer::default(), 1); + let state = + create_app_state_with_options(default_test_server_settings(), RunLayer::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/startup.rs b/lib/crates/fabro-server/src/startup.rs index 6bd2c957f..6507d86e0 100644 --- a/lib/crates/fabro-server/src/startup.rs +++ b/lib/crates/fabro-server/src/startup.rs @@ -28,13 +28,13 @@ pub fn validate_startup( mod tests { use std::collections::HashMap; - use fabro_config::parse_settings_layer; + use fabro_config::ServerSettingsBuilder; use fabro_types::settings::ServerNamespace; use super::validate_startup; fn resolved_settings(auth_methods: &[&str]) -> ServerNamespace { - let settings = parse_settings_layer(&format!( + ServerSettingsBuilder::from_toml(&format!( r" _version = 1 @@ -47,8 +47,8 @@ methods = [{}] .collect::>() .join(", ") )) - .unwrap(); - fabro_config::resolve_server_from_file(&settings).unwrap() + .unwrap() + .server } #[test] diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 410e29bd0..3d54f5cd1 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -830,11 +830,8 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::http::{HeaderMap, Request, StatusCode, header}; use axum_extra::extract::cookie::Key; - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::server::{ - GithubIntegrationLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerAuthMethod, - ServerIntegrationsLayer, ServerLayer, ServerWebLayer, - }; + use fabro_config::{RunLayer, ServerSettingsBuilder}; + use fabro_types::settings::server::ServerAuthMethod; use fabro_types::{IdpIdentity, RunAuthMethod}; use serde_json::json; use tower::ServiceExt; @@ -877,38 +874,47 @@ mod tests { .expect("test JWT key should derive") } - fn github_settings(web_url: &str) -> SettingsLayer { - SettingsLayer { - server: Some(ServerLayer { - web: Some(ServerWebLayer { - enabled: Some(true), - url: Some(web_url.into()), - }), - auth: Some(ServerAuthLayer { - methods: Some(vec![ServerAuthMethod::Github]), - github: Some(ServerAuthGithubLayer { - allowed_usernames: vec!["octocat".to_string()], - }), - }), - integrations: Some(ServerIntegrationsLayer { - github: Some(GithubIntegrationLayer { - client_id: Some("github-client-id".into()), - ..GithubIntegrationLayer::default() - }), - ..ServerIntegrationsLayer::default() - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - } + fn default_settings() -> fabro_types::ServerSettings { + ServerSettingsBuilder::from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] +"#, + ) + .expect("default test settings should resolve") + } + + fn github_settings(web_url: &str) -> fabro_types::ServerSettings { + ServerSettingsBuilder::from_toml(&format!( + r#" +_version = 1 + +[server.web] +enabled = true +url = "{web_url}" + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.integrations.github] +client_id = "github-client-id" +"# + )) + .expect("github settings should resolve") } fn test_auth_router_with_settings( - settings: SettingsLayer, + settings: fabro_types::ServerSettings, auth_mode: AuthMode, ) -> axum::Router { - let state = server::create_test_app_state_with_session_key( + let state = server::create_test_app_state_with_runtime_settings_and_session_key( settings, + RunLayer::default(), Some("web-auth-test-key-material-0123456789"), ); let middleware_state = state.clone(); @@ -925,7 +931,7 @@ mod tests { } fn test_auth_router(_key: &Key, auth_mode: AuthMode) -> axum::Router { - test_auth_router_with_settings(SettingsLayer::default(), auth_mode) + test_auth_router_with_settings(default_settings(), auth_mode) } macro_rules! response_json { @@ -1044,7 +1050,7 @@ mod tests { #[tokio::test] async fn auth_me_returns_unauthorized_under_demo_mode_without_jwt() { - let app = test_auth_router_with_settings(SettingsLayer::default(), dev_token_auth_mode()); + let app = test_auth_router_with_settings(default_settings(), dev_token_auth_mode()); let response = app .oneshot( @@ -1102,8 +1108,9 @@ mod tests { #[tokio::test] async fn auth_config_returns_real_methods_when_demo_cookie_set() { - let state = server::create_test_app_state_with_session_key( + let state = server::create_test_app_state_with_runtime_settings_and_session_key( github_settings("https://fabro.example"), + RunLayer::default(), Some("web-auth-test-key-material-0123456789"), ); let app = server::build_router_with_options( @@ -1194,8 +1201,9 @@ mod tests { #[tokio::test] async fn login_github_uses_injected_github_endpoints() { - let state = server::create_test_app_state_with_session_key( + let state = server::create_test_app_state_with_runtime_settings_and_session_key( github_settings("https://fabro.example"), + RunLayer::default(), Some("web-auth-test-key-material-0123456789"), ); let app = crate::server::build_router_with_options( diff --git a/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs b/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs index 1581fc988..43fc13019 100644 --- a/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs +++ b/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs @@ -1,26 +1,24 @@ -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use base64::Engine; -use fabro_config::{parse_settings_layer, resolve_server_from_file}; use fabro_server::ip_allowlist::IpAllowlistConfig; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; -use fabro_server::server::{RouterOptions, build_router_with_options, create_app_state_with_store}; +use fabro_server::server::{ + RouterOptions, build_router_with_options, create_app_state_with_store_and_runtime_settings, +}; use fabro_store::{ArtifactStore, AuthCode, Database, RefreshToken}; use object_store::memory::InMemory; use sha2::{Digest, Sha256}; use tower::ServiceExt; use uuid::Uuid; -use crate::helpers::body_json; +use crate::helpers::{body_json, settings_from_toml}; -fn settings(source: &str) -> fabro_types::settings::SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") -} - -fn test_app(settings: fabro_types::settings::SettingsLayer) -> (axum::Router, Arc) { +fn test_app(source: &str) -> (axum::Router, Arc) { + let settings = settings_from_toml(source); let object_store: Arc = Arc::new(InMemory::new()); let store = Arc::new(Database::new( Arc::clone(&object_store), @@ -29,16 +27,17 @@ fn test_app(settings: fabro_types::settings::SettingsLayer) -> (axum::Router, Ar None, )); let artifact_store = ArtifactStore::new(object_store, "artifacts"); - let resolved = resolve_server_from_file(&settings).expect("settings should resolve"); - let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name { - "SESSION_SECRET" => Some("0123456789abcdef0123456789abcdef".to_string()), - "GITHUB_APP_CLIENT_SECRET" => Some("test-client-secret".to_string()), - _ => None, - }) - .expect("auth mode should resolve"); + let auth_mode = + resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name { + "SESSION_SECRET" => Some("0123456789abcdef0123456789abcdef".to_string()), + "GITHUB_APP_CLIENT_SECRET" => Some("test-client-secret".to_string()), + _ => None, + }) + .expect("auth mode should resolve"); let app = build_router_with_options( - create_app_state_with_store( - Arc::new(RwLock::new(settings)), + create_app_state_with_store_and_runtime_settings( + settings.server_settings, + settings.manifest_run_defaults, 5, Arc::clone(&store), artifact_store, @@ -60,7 +59,7 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] { #[tokio::test] async fn cli_auth_token_exchanges_code_over_public_router() { - let (app, store) = test_app(settings( + let (app, store) = test_app( r#" _version = 1 @@ -76,7 +75,7 @@ url = "https://fabro.example" [server.integrations.github] client_id = "Iv1.test" "#, - )); + ); let auth_codes = store.auth_codes().await.unwrap(); auth_codes .insert(AuthCode { @@ -127,7 +126,7 @@ client_id = "Iv1.test" #[tokio::test] async fn cli_auth_refresh_replay_revokes_chain_over_public_router() { - let (app, store) = test_app(settings( + let (app, store) = test_app( r#" _version = 1 @@ -143,7 +142,7 @@ url = "https://fabro.example" [server.integrations.github] client_id = "Iv1.test" "#, - )); + ); let auth_tokens = store.refresh_tokens().await.unwrap(); let now = chrono::Utc::now(); auth_tokens diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 95bc8d4d6..0a516a2b4 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -10,7 +10,7 @@ use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_config::{Storage, parse_settings_layer, resolve_server_from_file}; +use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_install::OBJECT_STORE_MANAGED_COMMENT; use fabro_model::Provider; use fabro_server::install::{InstallAppState, build_install_router}; @@ -744,8 +744,9 @@ async fn token_install_finish_persists_settings_env_and_vault() { let settings = std::fs::read_to_string(&config_path).unwrap(); assert!(settings.contains("https://fabro.example.com")); assert!(settings.contains("strategy = \"token\"")); - let parsed = parse_settings_layer(&settings).expect("settings should parse"); - let resolved = resolve_server_from_file(&parsed).expect("settings should resolve"); + let resolved = ServerSettingsBuilder::from_toml(&settings) + .expect("settings should resolve") + .server; assert_eq!( match resolved.listen { fabro_types::settings::server::ServerListenSettings::Tcp { address, .. } => { diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 4deb779a5..153c39337 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -4,24 +4,25 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Method, Request, StatusCode}; -use fabro_config::{parse_settings_layer, resolve_server_from_file}; +use fabro_config::ServerSettingsBuilder; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::server::{ RouterOptions, build_router, build_router_with_options, create_app_state, - create_app_state_with_options, + create_app_state_with_runtime_settings_and_options, }; -use fabro_types::settings::SettingsLayer; use tower::ServiceExt; -use crate::helpers::{checked_response, response_json, response_status, response_text}; +use crate::helpers::{ + checked_response, response_json, response_status, response_text, settings_from_toml, +}; const DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; const SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; fn dev_token_enabled_auth_mode() -> AuthMode { - let settings = parse_settings_layer( + let resolved = ServerSettingsBuilder::from_toml( r#" _version = 1 @@ -29,8 +30,8 @@ _version = 1 methods = ["dev-token"] "#, ) - .expect("settings fixture should parse"); - let resolved = resolve_server_from_file(&settings).expect("settings should resolve"); + .expect("settings should resolve") + .server; resolve_auth_mode_with_lookup(&resolved, |name| match name { "SESSION_SECRET" => Some(SESSION_SECRET.to_string()), "FABRO_DEV_TOKEN" => Some(DEV_TOKEN.to_string()), @@ -412,17 +413,20 @@ async fn security_headers_are_applied_to_all_responses() { #[tokio::test] async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() { - let settings: SettingsLayer = parse_settings_layer( + let settings = settings_from_toml( r" _version = 1 [server.web] enabled = false ", - ) - .expect("settings fixture should parse"); + ); let app = build_router_with_options( - create_app_state_with_options(settings, 5), + create_app_state_with_runtime_settings_and_options( + settings.server_settings, + settings.manifest_run_defaults, + 5, + ), &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { @@ -474,17 +478,20 @@ enabled = false #[tokio::test] async fn web_disabled_ignores_demo_header_dispatch() { - let settings: SettingsLayer = parse_settings_layer( + let settings = settings_from_toml( r" _version = 1 [server.web] enabled = false ", - ) - .expect("settings fixture should parse"); + ); let app = build_router_with_options( - create_app_state_with_options(settings, 5), + create_app_state_with_runtime_settings_and_options( + settings.server_settings, + settings.manifest_run_defaults, + 5, + ), &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index 758c69d07..f77a233d2 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -1,18 +1,18 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_config::parse_settings_layer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, test_app_state_with_options, + MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, settings_from_toml, + test_app_state_with_options, }; #[tokio::test] -async fn retrieve_run_settings_returns_persisted_layer_without_redaction() { +async fn retrieve_run_settings_returns_dense_snapshot() { let storage_dir = tempfile::tempdir().unwrap(); - let settings = parse_settings_layer(&format!( + let settings = settings_from_toml(&format!( r#" _version = 1 @@ -38,8 +38,7 @@ client_id = "Iv1.github" slug = "fabro-app" "#, storage_dir.path().display() - )) - .expect("settings fixture should parse"); + )); let app = build_router(test_app_state_with_options(settings, 5), AuthMode::Disabled); let mut manifest = minimal_manifest_json(MINIMAL_DOT); @@ -87,20 +86,12 @@ session_sandboxes = true format!("GET /api/v1/runs/{run_id}/settings"), ) .await; - assert_eq!(body["_version"], 1); - assert_eq!(body["run"]["goal"], "Ship it"); - assert_eq!(body["cli"]["output"]["verbosity"], "verbose"); - assert_eq!(body["features"]["session_sandboxes"], true); - assert_eq!( - body["server"]["storage"]["root"], - storage_dir.path().display().to_string() - ); - assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); - assert!(body.pointer("/server/integrations/github/app_id").is_none()); - assert!( - body.pointer("/server/integrations/github/client_id") - .is_none() - ); - assert!(body.pointer("/server/auth").is_none()); - assert!(body.pointer("/server/listen").is_none()); + assert_eq!(body["project"]["directory"], "."); + assert_eq!(body["workflow"]["graph"], "workflow.fabro"); + assert_eq!(body["run"]["goal"]["type"], "inline"); + assert_eq!(body["run"]["goal"]["value"], "Ship it"); + assert!(body.pointer("/_version").is_none()); + assert!(body.pointer("/cli").is_none()); + assert!(body.pointer("/features").is_none()); + assert!(body.pointer("/server").is_none()); } diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index 1495b43da..3afea1b87 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -1,16 +1,14 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_config::parse_settings_layer; use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state_with_options}; -use fabro_types::settings::SettingsLayer; +use fabro_server::server::{build_router, create_app_state_with_runtime_settings_and_options}; use tower::ServiceExt; -use crate::helpers::response_json; +use crate::helpers::{response_json, settings_from_toml}; #[tokio::test] async fn retrieve_server_settings_returns_dense_server_settings_from_app_state() { - let settings: SettingsLayer = parse_settings_layer( + let settings = settings_from_toml( r#" _version = 1 @@ -33,10 +31,13 @@ allowed_usernames = ["alice"] [server.integrations.github] client_id = "Iv1.abcdef" "#, - ) - .expect("settings fixture should parse"); + ); let app = build_router( - create_app_state_with_options(settings, 5), + create_app_state_with_runtime_settings_and_options( + settings.server_settings, + settings.manifest_run_defaults, + 5, + ), AuthMode::Disabled, ); diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index 184eb0625..ce2c0a727 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -9,15 +9,13 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_config::Storage; use fabro_types::RunId; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::interp::InterpString; -use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; use tempfile::tempdir; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, checked_response, minimal_manifest_json, - minimal_manifest_json_with_dry_run, response_json, response_status, + MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, TestAppSettings, api, checked_response, + minimal_manifest_json, minimal_manifest_json_with_dry_run, response_json, response_status, test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status, }; @@ -37,14 +35,12 @@ const HUMAN_GATE_DOT: &str = r#"digraph GateTest { revise -> gate }"#; -fn temp_storage_settings() -> (tempfile::TempDir, SettingsLayer, PathBuf) { +fn temp_storage_settings() -> (tempfile::TempDir, TestAppSettings, PathBuf) { let temp = tempdir().expect("tempdir should create"); let mut settings = test_settings(); let storage_dir = temp.path().join("storage"); - let server = settings.server.get_or_insert_with(ServerLayer::default); - server.storage = Some(ServerStorageLayer { - root: Some(InterpString::parse(&storage_dir.to_string_lossy())), - }); + settings.server_settings.server.storage.root = + InterpString::parse(&storage_dir.to_string_lossy()); (temp, settings, storage_dir) } diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 24d35e242..733211846 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -10,7 +10,7 @@ use std::time::Duration; use axum::http::StatusCode; use fabro_config::bind::Bind; -use fabro_config::{RuntimeDirectory, parse_settings_layer, resolve_server_from_file}; +use fabro_config::{RuntimeDirectory, ServerSettingsBuilder}; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::serve::{ServeArgs, serve_command}; @@ -106,7 +106,12 @@ async fn spawn_served_listener( .await }); - let bind = rx.await.expect("server should report its bind address"); + let Ok(bind) = rx.await else { + let result = handle + .await + .expect("server task should not panic before reporting readiness"); + panic!("server should report its bind address: {result:?}"); + }; (handle, bind, tempdir) } @@ -159,7 +164,7 @@ methods = ["dev-token"] #[tokio::test] async fn tcp_dev_token_auth_uses_bearer_auth() { - let settings = parse_settings_layer( + let resolved = ServerSettingsBuilder::from_toml( r#" _version = 1 @@ -167,8 +172,8 @@ _version = 1 methods = ["dev-token"] "#, ) - .expect("test settings should parse"); - let resolved = resolve_server_from_file(&settings).expect("test settings should resolve"); + .expect("test settings should resolve") + .server; let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name { "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), "FABRO_DEV_TOKEN" => Some(TEST_DEV_TOKEN.to_string()), diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index b082c778c..c1c062c73 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -4,17 +4,19 @@ use std::time::Duration; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; +use fabro_config::{LocalSandboxLayer, RunLayer, RunSandboxLayer, ServerSettingsBuilder}; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ - AppState, build_router, create_app_state, create_app_state_with_env_lookup, - create_app_state_with_options_and_registry_factory, spawn_scheduler, + AppState, build_router, create_app_state, + create_app_state_with_runtime_settings_and_env_lookup, + create_app_state_with_runtime_settings_and_options_and_registry_factory, spawn_scheduler, }; use fabro_test::{ assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status, expect_axum_status_in, expect_axum_text, }; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{LocalSandboxLayer, RunLayer, RunSandboxLayer, WorktreeMode}; +use fabro_types::ServerSettings; +use fabro_types::settings::run::WorktreeMode; use tokio::time::sleep; use tower::ServiceExt; @@ -28,24 +30,71 @@ pub(crate) const MINIMAL_DOT: &str = r#"digraph Test { pub(crate) const POLL_INTERVAL: Duration = Duration::from_millis(10); pub(crate) const POLL_ATTEMPTS: usize = 500; +#[derive(Clone)] +pub(crate) struct TestAppSettings { + pub server_settings: ServerSettings, + pub manifest_run_defaults: RunLayer, +} + +impl Default for TestAppSettings { + fn default() -> Self { + settings_from_toml("_version = 1\n") + } +} + +fn ensure_test_auth_methods(document: &mut toml::Table) { + let server = document + .entry("server") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .expect("[server] should stay a table in test fixtures"); + let auth = server + .entry("auth") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .expect("[server.auth] should stay a table in test fixtures"); + auth.entry("methods") + .or_insert_with(|| toml::Value::Array(vec![toml::Value::String("dev-token".to_string())])); +} + +pub(crate) fn settings_from_toml(source: &str) -> TestAppSettings { + let mut document: toml::Table = source.parse().expect("test fixture should parse as TOML"); + ensure_test_auth_methods(&mut document); + let manifest_run_defaults = document + .remove("run") + .map(toml::Value::try_into::) + .transpose() + .expect("test run settings should parse") + .unwrap_or_default(); + let server_settings = ServerSettingsBuilder::from_toml( + &toml::to_string(&document).expect("test fixture should serialize"), + ) + .expect("test server settings should resolve"); + TestAppSettings { + server_settings, + manifest_run_defaults, + } +} + pub(crate) fn test_app_state() -> Arc { create_app_state() } pub(crate) fn test_app_state_with_options( - settings: SettingsLayer, + settings: TestAppSettings, max_concurrent_runs: usize, ) -> Arc { - create_app_state_with_options_and_registry_factory( - settings, + create_app_state_with_runtime_settings_and_options_and_registry_factory( + settings.server_settings, + settings.manifest_run_defaults, max_concurrent_runs, |interviewer| fabro_workflow::handler::default_registry(interviewer, || None), ) } -pub(crate) fn test_settings() -> SettingsLayer { - SettingsLayer { - run: Some(RunLayer { +pub(crate) fn test_settings() -> TestAppSettings { + TestAppSettings { + manifest_run_defaults: RunLayer { sandbox: Some(RunSandboxLayer { local: Some(LocalSandboxLayer { worktree_mode: Some(WorktreeMode::Never), @@ -53,8 +102,8 @@ pub(crate) fn test_settings() -> SettingsLayer { ..RunSandboxLayer::default() }), ..RunLayer::default() - }), - ..SettingsLayer::default() + }, + ..TestAppSettings::default() } } @@ -64,17 +113,29 @@ pub(crate) fn test_app_with_scheduler(state: Arc) -> axum::Router { } pub(crate) fn test_app_with_no_providers() -> axum::Router { - let state = create_app_state_with_env_lookup(test_settings(), 5, |_| None); + let settings = test_settings(); + let state = create_app_state_with_runtime_settings_and_env_lookup( + settings.server_settings, + settings.manifest_run_defaults, + 5, + |_| None, + ); build_router(state, AuthMode::Disabled) } pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router { let base_url = mock_base_url.to_string(); - let state = create_app_state_with_env_lookup(test_settings(), 5, move |name| match name { - "ANTHROPIC_API_KEY" => Some("test-key".to_string()), - "ANTHROPIC_BASE_URL" => Some(base_url.clone()), - _ => None, - }); + let settings = test_settings(); + let state = create_app_state_with_runtime_settings_and_env_lookup( + settings.server_settings, + settings.manifest_run_defaults, + 5, + move |name| match name { + "ANTHROPIC_API_KEY" => Some("test-key".to_string()), + "ANTHROPIC_BASE_URL" => Some(base_url.clone()), + _ => None, + }, + ); build_router(state, AuthMode::Disabled) } diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index a12b067bf..bab9d4c68 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -12,7 +12,9 @@ use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use fabro_server::install::{InstallAppState, build_install_router}; use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state_with_env_lookup_and_server_secret_env}; +use fabro_server::server::{ + build_router, create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env, +}; use serde_yaml::Value; use tower::ServiceExt; @@ -145,9 +147,11 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() { #[tokio::test] async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() { let secret = "test-webhook-secret".to_string(); + let settings = test_settings(); let app = build_router( - create_app_state_with_env_lookup_and_server_secret_env( - test_settings(), + create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( + settings.server_settings, + settings.manifest_run_defaults, 5, |_| None, &std::collections::HashMap::from([("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)]), @@ -214,8 +218,8 @@ async fn install_and_normal_routes_stay_isolated() { // 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 -// `SettingsLayer` tree which the OpenAPI spec declares as -// `type: object, additionalProperties: true`, so there is nothing to diff -// at the property-key level. +// struct that it instantiated. `/api/v1/settings` now returns dense +// `ServerSettings`, and `/api/v1/runs/:id/settings` returns a dense +// `WorkflowSettings` snapshot. Property-level conformance for those payloads +// lives in the `fabro-api` round-trip tests that pin the Rust types against +// the OpenAPI schema names. diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index e8729ee0b..3ec3c3776 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -5,7 +5,7 @@ use axum::http::{Request, StatusCode}; use fabro_interview::Interviewer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ - build_router, create_app_state_with_settings_and_registry_factory, spawn_scheduler, + build_router, create_app_state_with_runtime_settings_and_registry_factory, spawn_scheduler, }; use fabro_workflow::handler::HandlerRegistry; use fabro_workflow::handler::agent::AgentHandler; @@ -119,7 +119,12 @@ const GATE_DOT: &str = r#"digraph GateTest { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_http_lifecycle_approve_and_complete() { - let state = create_app_state_with_settings_and_registry_factory(test_settings(), gate_registry); + let settings = test_settings(); + let state = create_app_state_with_runtime_settings_and_registry_factory( + settings.server_settings, + settings.manifest_run_defaults, + gate_registry, + ); spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), AuthMode::Disabled); @@ -202,7 +207,12 @@ async fn full_http_lifecycle_approve_and_complete() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_http_lifecycle_cancel() { - let state = create_app_state_with_settings_and_registry_factory(test_settings(), gate_registry); + let settings = test_settings(); + let state = create_app_state_with_runtime_settings_and_registry_factory( + settings.server_settings, + settings.manifest_run_defaults, + gate_registry, + ); spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), AuthMode::Disabled); @@ -268,7 +278,12 @@ async fn full_http_lifecycle_cancel() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancel_at_human_gate_persists_cancelled_terminal_event() { - let state = create_app_state_with_settings_and_registry_factory(test_settings(), gate_registry); + let settings = test_settings(); + let state = create_app_state_with_runtime_settings_and_registry_factory( + settings.server_settings, + settings.manifest_run_defaults, + gate_registry, + ); spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), AuthMode::Disabled); diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 7cbd25475..126201ec2 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -559,10 +559,10 @@ mod tests { use fabro_types::run_event::{ InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunControlEffectProps, }; - use fabro_types::settings::SettingsLayer; use fabro_types::{ BlockedReason, Checkpoint, EventBody, FailureReason, InterviewQuestionType, NodeState, - RunBlobId, RunControlAction, RunEvent, RunStatus, SuccessReason, TerminalStatus, fixtures, + RunBlobId, RunControlAction, RunEvent, RunStatus, SuccessReason, TerminalStatus, + WorkflowSettings, fixtures, }; use serde_json::json; @@ -647,7 +647,7 @@ mod tests { let state: RunProjection = serde_json::from_value(serde_json::json!({ "spec": { "run_id": "01JW6A7VNFZSFF0SKXJG29Z2M3", - "settings": { "_version": 1 }, + "settings": WorkflowSettings::default(), "graph": { "name": "ship", "nodes": {}, "edges": [], "attrs": {} }, "workflow_slug": "demo", "working_directory": "/tmp/project", @@ -980,7 +980,7 @@ mod tests { let mut state = RunProjection::default(); state.spec = Some(fabro_types::RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: fabro_types::Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: std::path::PathBuf::from("/tmp/run"), @@ -1011,7 +1011,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": SettingsLayer::default(), + "settings": WorkflowSettings::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 76b213371..dce39e16c 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -302,9 +302,9 @@ mod tests { use std::path::PathBuf; use chrono::{DateTime, Utc}; - use fabro_types::settings::SettingsLayer; use fabro_types::{ AttrValue, FailureReason, Graph, RunControlAction, RunSpec, RunStatus, SuccessReason, + WorkflowSettings, }; use futures::TryStreamExt; use object_store::memory::InMemory; @@ -355,7 +355,7 @@ mod tests { ); RunSpec { run_id: test_run_id(label), - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from(format!("/tmp/{label}")), diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index 39288f902..fd5abb557 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -5,17 +5,16 @@ use chrono::{TimeZone, Utc}; use fabro_store::{NodeState, RunProjection, SerializableProjection, StageId}; use fabro_types::graph::Graph; use fabro_types::run::RunSpec; -use fabro_types::settings::SettingsLayer; use fabro_types::{ Checkpoint, NodeStatusRecord, RunStatus, SandboxRecord, StageStatus, StartRecord, - TerminalStatus, fixtures, + TerminalStatus, WorkflowSettings, fixtures, }; use serde_json::json; fn sample_run_spec() -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: Graph::new("ship"), workflow_slug: Some("demo".to_string()), working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index 07b75903c..67d2fbcdb 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -21,7 +21,6 @@ workspace = true chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, optional = true } dirs.workspace = true -fabro-macros = { path = "../fabro-macros" } fabro-model = { path = "../fabro-model" } fabro-util = { path = "../fabro-util" } hex.workspace = true diff --git a/lib/crates/fabro-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs new file mode 100644 index 000000000..876daf05c --- /dev/null +++ b/lib/crates/fabro-types/src/dense.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::settings::{ + CliNamespace, FeaturesNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, + RunNamespace, ServerNamespace, WorkflowNamespace, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ServerSettings { + pub server: ServerNamespace, + pub features: FeaturesNamespace, +} + +impl ServerSettings { + #[must_use] + pub fn with_storage_override(mut self, path: &Path) -> Self { + self.server.storage.root = InterpString::parse(&path.display().to_string()); + override_local_object_store_root(&mut self.server.artifacts.store, path, "artifacts"); + override_local_object_store_root(&mut self.server.slatedb.store, path, "slatedb"); + self + } +} + +fn override_local_object_store_root( + store: &mut ObjectStoreSettings, + storage_root: &Path, + domain: &str, +) { + let ObjectStoreSettings::Local { root } = store else { + return; + }; + *root = InterpString::parse( + &storage_root + .join("objects") + .join(domain) + .display() + .to_string(), + ); +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UserSettings { + pub cli: CliNamespace, + pub features: FeaturesNamespace, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct WorkflowSettings { + pub project: ProjectNamespace, + pub workflow: WorkflowNamespace, + pub run: RunNamespace, +} + +impl WorkflowSettings { + #[must_use] + pub fn combined_labels(&self) -> HashMap { + let mut labels = self.project.metadata.clone(); + labels.extend(self.workflow.metadata.clone()); + labels.extend(self.run.metadata.clone()); + labels + } +} diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 908b468e2..3a51d32d4 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -6,6 +6,7 @@ pub mod billing; pub mod blob_ref; pub mod checkpoint; pub mod conclusion; +pub mod dense; pub mod event_envelope; pub mod failure_signature; pub mod graph; @@ -39,6 +40,7 @@ pub use blob_ref::{ }; pub use checkpoint::Checkpoint; pub use conclusion::{Conclusion, StageSummary}; +pub use dense::{ServerSettings, UserSettings, WorkflowSettings}; pub use event_envelope::EventEnvelope; pub use failure_signature::FailureSignature; pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type}; @@ -63,7 +65,6 @@ pub use run_id::{RunId, fixtures}; pub use run_projection::{NodeState, PendingInterviewRecord, RunProjection}; pub use run_summary::RunSummary; pub use sandbox_record::SandboxRecord; -pub use settings::Combine; 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 7a3e9ff4c..104547330 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -3,10 +3,10 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::WorkflowSettings; use crate::graph::Graph; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -use crate::settings::SettingsLayer; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -51,7 +51,7 @@ pub struct RunProvenance { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunSpec { pub run_id: RunId, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, @@ -84,7 +84,7 @@ impl RunSpec { } #[must_use] - pub fn settings(&self) -> &SettingsLayer { + pub fn settings(&self) -> &WorkflowSettings { &self.settings } diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index e296f323f..99ad5928b 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -809,8 +809,7 @@ mod tests { use serde_json::json; use super::*; - use crate::settings::SettingsLayer; - use crate::{Edge, Graph, Node, RunBlobId, fixtures}; + use crate::{Edge, Graph, Node, RunBlobId, WorkflowSettings, fixtures}; #[test] fn run_event_round_trips_json() { @@ -859,7 +858,7 @@ mod tests { #[test] fn run_event_deserializes_adjacent_layout() { - let settings = SettingsLayer::default(); + let settings = WorkflowSettings::default(); let graph = Graph { name: "test".to_string(), nodes: HashMap::from([("start".to_string(), Node { @@ -901,7 +900,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": SettingsLayer::default(), + "settings": WorkflowSettings::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 5f02ef13c..47df2d79f 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -3,13 +3,12 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use super::{ActorRef, BilledTokenCounts, RunNoticeLevel}; -use crate::settings::SettingsLayer; use crate::status::{BlockedReason, FailureReason, SuccessReason}; -use crate::{Graph, RunBlobId, RunControlAction, RunProvenance}; +use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, WorkflowSettings}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCreatedProps { - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_source: Option, diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index 46bf0abac..19aa30d76 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -10,11 +10,10 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::interp::InterpString; -use super::maps::StickyMap; -use super::run::{AgentPermissions, McpEntryLayer, McpServerSettings}; +use super::run::{AgentPermissions, McpServerSettings}; /// A structurally resolved `[cli]` view for consumers. -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliNamespace { pub target: Option, pub auth: CliAuthSettings, @@ -24,94 +23,53 @@ pub struct CliNamespace { pub logging: CliLoggingSettings, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum CliTargetSettings { Http { url: InterpString }, Unix { path: InterpString }, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliAuthSettings { pub strategy: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliExecSettings { pub prevent_idle_sleep: bool, pub model: CliExecModelSettings, pub agent: CliExecAgentSettings, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliExecModelSettings { pub provider: Option, pub name: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliExecAgentSettings { pub permissions: Option, pub mcps: HashMap, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliOutputSettings { pub format: OutputFormat, pub verbosity: OutputVerbosity, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliUpdatesSettings { pub check: bool, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CliLoggingSettings { pub level: Option, } -/// A sparse `[cli]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, - }, - Unix { - #[serde(default)] - path: 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 { @@ -119,48 +77,6 @@ pub enum CliAuthStrategy { Jwt, } -/// `[cli.exec]` — `fabro exec` defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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 = "StickyMap::is_empty")] - pub mcps: StickyMap, -} - -/// `[cli.output]` — generic CLI output defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum OutputFormat { @@ -177,19 +93,3 @@ pub enum OutputVerbosity { Normal, Verbose, } - -/// `[cli.updates]` — upgrade check toggle. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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/features.rs b/lib/crates/fabro-types/src/settings/features.rs index 79dfc9f59..bd82b630f 100644 --- a/lib/crates/fabro-types/src/settings/features.rs +++ b/lib/crates/fabro-types/src/settings/features.rs @@ -10,14 +10,3 @@ use serde::{Deserialize, Serialize}; pub struct FeaturesNamespace { pub session_sandboxes: bool, } - -/// 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/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 95831e6cd..81c31f134 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,60 +1,51 @@ //! 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. +//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. Value-language +//! helpers live alongside the tree: durations, byte sizes, model references, +//! and env interpolation. //! //! Stage 6.5b promoted these modules up out of the transitional //! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer //! exists. pub mod cli; -pub mod combine; pub mod duration; pub mod features; pub mod interp; -pub mod layer; -pub mod maps; pub mod model_ref; pub mod project; pub mod run; pub mod server; pub mod size; -pub mod splice_array; pub mod workflow; pub use cli::{ - CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings, CliLayer, + CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings, }; -pub use combine::Combine; pub use duration::{Duration, ParseDurationError}; -pub use features::{FeaturesLayer, FeaturesNamespace}; +pub use features::FeaturesNamespace; pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; -pub use layer::SettingsLayer; -pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use model_ref::{ AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, }; -pub use project::{ProjectLayer, ProjectNamespace}; +pub use project::ProjectNamespace; pub use run::{ ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings, McpTransport, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal, - RunInterviewsSettings, RunLayer, RunModelSettings, RunNamespace, RunPrepareSettings, - RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode, + RunInterviewsSettings, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, + RunScmSettings, ScmGitHubSettings, TlsMode, }; pub use server::{ DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, - ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, - ServerIpAllowlistSettings, ServerLayer, ServerListenSettings, ServerLoggingSettings, - ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, - ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, + ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerListenSettings, + ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings, + ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, }; pub use size::{ParseSizeError, Size}; -pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; -pub use workflow::{WorkflowLayer, WorkflowNamespace}; +pub use workflow::WorkflowNamespace; diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index 9b4098bc8..7e141dbcb 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -7,29 +7,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::maps::ReplaceMap; - /// A structurally resolved `[project]` view for consumers. -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ProjectNamespace { pub name: Option, pub description: Option, pub directory: String, pub metadata: HashMap, } - -/// A sparse `[project]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 - /// `.` after layering when unspecified. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub directory: Option, - #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, -} diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index fea4d7f85..1d4626aef 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -12,13 +12,11 @@ use std::time::Duration as StdDuration; use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; -use super::duration::Duration; use super::interp::InterpString; -use super::maps::{MergeMap, ReplaceMap, StickyMap}; use super::model_ref::ModelRef; /// A structurally resolved `[run]` view for consumers. -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunNamespace { pub goal: Option, pub working_dir: Option, @@ -40,32 +38,32 @@ pub struct RunNamespace { } /// The resolved source of a run goal. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum RunGoal { Inline(InterpString), File(InterpString), } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunModelSettings { pub provider: Option, pub name: Option, pub fallbacks: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunGitSettings { pub author: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct GitAuthorSettings { pub name: Option, pub email: Option, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunPrepareSettings { pub commands: Vec, pub timeout_ms: u64, @@ -80,7 +78,7 @@ impl Default for RunPrepareSettings { } } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunExecutionSettings { pub mode: RunMode, pub approval: ApprovalMode, @@ -97,12 +95,12 @@ impl Default for RunExecutionSettings { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunCheckpointSettings { pub exclude_globs: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSandboxSettings { pub provider: String, pub preserve: bool, @@ -125,12 +123,12 @@ impl Default for RunSandboxSettings { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct LocalSandboxSettings { pub worktree_mode: WorktreeMode, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct DaytonaSettings { pub auto_stop_interval: Option, pub labels: HashMap, @@ -145,6 +143,13 @@ pub enum DockerfileSource { Path { path: String }, } +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum DockerfileSourceRepr { + Inline { value: String }, + Path { path: String }, +} + impl Serialize for DockerfileSource { fn serialize(&self, serializer: S) -> Result where @@ -165,7 +170,19 @@ impl Serialize for DockerfileSource { } } -#[derive(Debug, Clone, PartialEq, Serialize)] +impl<'de> Deserialize<'de> for DockerfileSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match DockerfileSourceRepr::deserialize(deserializer)? { + DockerfileSourceRepr::Inline { value } => Ok(Self::Inline(value)), + DockerfileSourceRepr::Path { path } => Ok(Self::Path { path }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DaytonaSnapshotSettings { pub name: String, pub cpu: Option, @@ -174,7 +191,7 @@ pub struct DaytonaSnapshotSettings { pub dockerfile: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct NotificationRouteSettings { pub enabled: bool, pub provider: Option, @@ -184,12 +201,12 @@ pub struct NotificationRouteSettings { pub teams: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct NotificationProviderSettings { pub channel: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunInterviewsSettings { pub provider: Option, pub slack: Option, @@ -197,18 +214,18 @@ pub struct RunInterviewsSettings { pub teams: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct InterviewProviderSettings { pub channel: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunAgentSettings { pub permissions: Option, pub mcps: HashMap, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct McpServerSettings { pub name: String, pub transport: McpTransport, @@ -242,7 +259,7 @@ impl McpServerSettings { } } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum McpTransport { Stdio { @@ -372,7 +389,7 @@ impl HookDefinition { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunScmSettings { pub provider: Option, pub owner: Option, @@ -380,20 +397,14 @@ pub struct RunScmSettings { pub github: Option, } -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ScmGitHubSettings; +#[expect( + clippy::empty_structs_with_brackets, + reason = "resolved empty table must stay object-shaped on the wire" +)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ScmGitHubSettings {} -impl Serialize for ScmGitHubSettings { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let state = serializer.serialize_struct("ScmGitHubSettings", 0)?; - state.end() - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PullRequestSettings { pub enabled: bool, pub draft: bool, @@ -412,86 +423,14 @@ impl Default for PullRequestSettings { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ArtifactsSettings { pub include: Vec, } - -/// A sparse `[run]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, - /// 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 = "MergeMap::is_empty")] - pub notifications: MergeMap, - #[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. +/// Outcome of resolving a [`RunGoal`] 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. +/// run manifest builder) can distinguish inline goals from file-sourced goals. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedRunGoal { pub text: String, @@ -508,77 +447,6 @@ pub enum ResolvedGoalSource { File { path: std::path::PathBuf }, } -/// `[run.model]` — provider-neutral default model selection. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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)] @@ -591,19 +459,6 @@ pub struct PrepareStep { pub env: HashMap, } -/// `[run.execution]` — run posture knobs. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 { @@ -618,40 +473,6 @@ pub enum ApprovalMode { 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, -} - -/// `[run.sandbox]` — sandbox selection and execution-environment surface. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 = "StickyMap::is_empty")] - pub env: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daytona: Option, -} - -#[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(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WorktreeMode { @@ -662,44 +483,6 @@ pub enum WorktreeMode { Never, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 = "StickyMap::is_empty")] - pub labels: StickyMap, - #[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 { @@ -708,93 +491,6 @@ pub enum DaytonaNetworkLayer { AllowList { allow_list: Vec }, } -/// `[run.notifications.]` — a keyed notification route. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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 = "StickyMap::is_empty")] - pub mcps: StickyMap, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AgentPermissions { @@ -803,117 +499,6 @@ pub enum AgentPermissions { 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 { @@ -935,42 +520,6 @@ pub enum HookEvent { PostToolUseFailure, } -/// `[run.scm]` — remote SCM host/provider behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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 { @@ -978,11 +527,3 @@ pub enum MergeStrategy { Merge, Rebase, } - -/// `[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, -} diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 9c31198de..a80937d78 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -13,9 +13,8 @@ use ipnet::IpNet; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use super::duration::Duration as DurationLayer; +use super::duration::Duration; use super::interp::InterpString; -use super::maps::StickyMap; /// A structurally resolved `[server]` view for consumers. /// @@ -296,155 +295,14 @@ fn serialize_std_duration(value: &StdDuration, serializer: S) -> Result(deserializer: D) -> Result where D: Deserializer<'de>, { - Ok(DurationLayer::deserialize(deserializer)?.as_std()) -} - -/// A sparse `[server]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 ip_allowlist: 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, -} - -/// `[server.listen]` — shared bind transport. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] -pub enum ServerListenLayer { - Tcp { - #[serde(default)] - address: Option, - }, - Unix { - #[serde(default)] - path: 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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub methods: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthGithubLayer { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_usernames: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ServerIpAllowlistLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub entries: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trusted_proxy_count: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ServerIpAllowlistOverrideLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub entries: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trusted_proxy_count: Option, -} - -/// `[server.storage]` — single managed local disk root. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct ServerStorageLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub root: Option, -} - -/// `[server.artifacts]` — object-store-backed artifact storage. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, -} - -/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk_cache: Option, + Ok(Duration::deserialize(deserializer)?.as_std()) } /// Closed enum of object-store providers. Unknown providers hard-fail @@ -456,117 +314,6 @@ pub enum ObjectStoreProvider { S3, } -#[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}/objects/{domain}`. - #[serde(default, skip_serializing_if = "Option::is_none")] - 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 bucket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - 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, -} - -/// `[server.scheduler]` — server-managed execution policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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 strategy: 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 = "StickyMap::is_empty")] - pub permissions: StickyMap, - #[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[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, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct IntegrationWebhooksLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ip_allowlist: Option, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GithubIntegrationStrategy { diff --git a/lib/crates/fabro-types/src/settings/splice_array.rs b/lib/crates/fabro-types/src/settings/splice_array.rs deleted file mode 100644 index 35d312168..000000000 --- a/lib/crates/fabro-types/src/settings/splice_array.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! 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/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index e7d47d045..fd3a5b8b9 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -7,28 +7,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::maps::ReplaceMap; - /// A structurally resolved `[workflow]` view for consumers. -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct WorkflowNamespace { pub name: Option, pub description: Option, pub graph: String, pub metadata: HashMap, } - -/// A sparse `[workflow]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[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 = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, -} diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index 1ccb99cb1..8077807c8 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -1,37 +1,15 @@ use std::collections::BTreeMap; +use fabro_types::WorkflowSettings; use fabro_types::graph::Graph; use fabro_types::run_event::run::RunCreatedProps; -use fabro_types::settings::run::{RunGoalLayer, RunLayer}; -use fabro_types::settings::server::{ - GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerStorageLayer, -}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::RunGoal; -fn templated_settings() -> SettingsLayer { - SettingsLayer { - version: Some(1), - run: Some(RunLayer { - goal: Some(RunGoalLayer::Inline(InterpString::parse( - "Ship {{ env.TASK }}", - ))), - ..RunLayer::default() - }), - server: Some(ServerLayer { - storage: Some(ServerStorageLayer { - root: Some(InterpString::parse("{{ env.FABRO_STORAGE }}")), - }), - integrations: Some(ServerIntegrationsLayer { - github: Some(GithubIntegrationLayer { - app_id: Some(InterpString::parse("{{ env.GITHUB_APP_ID }}")), - ..GithubIntegrationLayer::default() - }), - ..ServerIntegrationsLayer::default() - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - } +fn templated_settings() -> WorkflowSettings { + let mut settings = WorkflowSettings::default(); + settings.run.goal = Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))); + settings } #[test] @@ -62,34 +40,7 @@ fn run_created_props_round_trip_templated_settings() { json ); assert_eq!( - round_trip - .settings - .run - .as_ref() - .and_then(|run| run.goal.as_ref()), - Some(&RunGoalLayer::Inline(InterpString::parse( - "Ship {{ env.TASK }}" - ))) - ); - assert_eq!( - round_trip - .settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source), - Some("{{ env.FABRO_STORAGE }}".to_string()) - ); - assert_eq!( - round_trip - .settings - .server - .as_ref() - .and_then(|server| server.integrations.as_ref()) - .and_then(|integrations| integrations.github.as_ref()) - .and_then(|github| github.app_id.as_ref()) - .map(InterpString::as_source), - Some("{{ env.GITHUB_APP_ID }}".to_string()) + round_trip.settings.run.goal, + Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))) ); } diff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/crates/fabro-types/tests/run_spec_methods.rs index 20a4f2942..3d8078688 100644 --- a/lib/crates/fabro-types/tests/run_spec_methods.rs +++ b/lib/crates/fabro-types/tests/run_spec_methods.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use fabro_types::fixtures; use fabro_types::graph::Graph; use fabro_types::run::RunSpec; -use fabro_types::settings::SettingsLayer; +use fabro_types::{WorkflowSettings, fixtures}; fn sample_run_spec() -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: Graph::new("ship"), workflow_slug: Some("demo".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -29,7 +28,7 @@ fn run_spec_getters_return_declared_fields() { assert_eq!(run_spec.id(), fixtures::RUN_1); assert_eq!(run_spec.graph().name, "ship"); - assert_eq!(run_spec.settings(), &SettingsLayer::default()); + assert_eq!(run_spec.settings(), &WorkflowSettings::default()); assert_eq!(run_spec.workflow_slug(), Some("demo")); assert_eq!(run_spec.working_directory(), Path::new("/tmp/project")); assert_eq!( diff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/crates/fabro-types/tests/run_spec_serde.rs index 8b9e59d05..da1f5e2e0 100644 --- a/lib/crates/fabro-types/tests/run_spec_serde.rs +++ b/lib/crates/fabro-types/tests/run_spec_serde.rs @@ -1,39 +1,16 @@ use std::collections::HashMap; use std::path::PathBuf; -use fabro_types::fixtures; use fabro_types::graph::Graph; use fabro_types::run::RunSpec; -use fabro_types::settings::run::{RunGoalLayer, RunLayer}; -use fabro_types::settings::server::{ - GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerStorageLayer, -}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::RunGoal; +use fabro_types::{WorkflowSettings, fixtures}; -fn templated_settings() -> SettingsLayer { - SettingsLayer { - version: Some(1), - run: Some(RunLayer { - goal: Some(RunGoalLayer::Inline(InterpString::parse( - "Ship {{ env.TASK }}", - ))), - ..RunLayer::default() - }), - server: Some(ServerLayer { - storage: Some(ServerStorageLayer { - root: Some(InterpString::parse("{{ env.FABRO_STORAGE }}")), - }), - integrations: Some(ServerIntegrationsLayer { - github: Some(GithubIntegrationLayer { - app_id: Some(InterpString::parse("{{ env.GITHUB_APP_ID }}")), - ..GithubIntegrationLayer::default() - }), - ..ServerIntegrationsLayer::default() - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - } +fn templated_settings() -> WorkflowSettings { + let mut settings = WorkflowSettings::default(); + settings.run.goal = Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))); + settings } #[test] @@ -62,34 +39,7 @@ fn run_spec_round_trips_templated_settings() { json ); assert_eq!( - round_trip - .settings - .run - .as_ref() - .and_then(|run| run.goal.as_ref()), - Some(&RunGoalLayer::Inline(InterpString::parse( - "Ship {{ env.TASK }}" - ))) - ); - assert_eq!( - round_trip - .settings - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.as_ref()) - .map(InterpString::as_source), - Some("{{ env.FABRO_STORAGE }}".to_string()) - ); - assert_eq!( - round_trip - .settings - .server - .as_ref() - .and_then(|server| server.integrations.as_ref()) - .and_then(|integrations| integrations.github.as_ref()) - .and_then(|github| github.app_id.as_ref()) - .map(InterpString::as_source), - Some("{{ env.GITHUB_APP_ID }}".to_string()) + round_trip.settings.run.goal, + Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))) ); } diff --git a/lib/crates/fabro-types/tests/server_settings_serde.rs b/lib/crates/fabro-types/tests/server_settings_serde.rs deleted file mode 100644 index fafc9fc4d..000000000 --- a/lib/crates/fabro-types/tests/server_settings_serde.rs +++ /dev/null @@ -1,23 +0,0 @@ -use fabro_types::settings::SettingsLayer; -use serde_json::json; - -#[test] -fn settings_layer_round_trips_github_integration_strategy() { - let source = json!({ - "_version": 1, - "server": { - "integrations": { - "github": { - "strategy": "token", - "app_id": "{{ env.GITHUB_APP_ID }}" - } - } - } - }); - - let settings: SettingsLayer = - serde_json::from_value(source.clone()).expect("settings should deserialize"); - let round_trip = serde_json::to_value(&settings).expect("settings should serialize"); - - assert_eq!(round_trip, source); -} diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 6eb049bb9..a02e2b4fd 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -3553,8 +3553,9 @@ mod tests { #[test] fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::settings::SettingsLayer; - use ::fabro_types::{Graph, RunAuthMethod, RunSubjectProvenance, fixtures}; + use ::fabro_types::{ + Graph, RunAuthMethod, RunSubjectProvenance, WorkflowSettings, fixtures, + }; let provenance = RunProvenance { server: None, @@ -3567,7 +3568,7 @@ mod tests { let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { run_id: fixtures::RUN_1, - settings: serde_json::to_value(SettingsLayer::default()).unwrap(), + settings: serde_json::to_value(WorkflowSettings::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 213ce94c0..a435de4d5 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -5,7 +5,7 @@ pub use fabro_checkpoint::META_BRANCH_PREFIX; pub use fabro_checkpoint::author::GitAuthor; use fabro_checkpoint::git::Store; pub use fabro_checkpoint::metadata::MetadataStore; -use fabro_types::settings::SettingsLayer; +use fabro_types::WorkflowSettings; use tokio::task::{JoinError, spawn_blocking}; use tokio::time::timeout; @@ -14,10 +14,12 @@ use crate::error::{Error, Result}; /// 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: &SettingsLayer) -> GitAuthor { - fabro_config::resolve_run_from_file(settings) - .ok() - .and_then(|settings| settings.git.author) +pub fn git_author_from_settings(settings: &WorkflowSettings) -> GitAuthor { + settings + .run + .git + .author + .clone() .map(|author| GitAuthor::from(&author)) .unwrap_or_default() } diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 075319dc2..d6d8c98f4 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -7,7 +7,7 @@ use std::time::Duration; use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::settings::SettingsLayer; +use fabro_types::WorkflowSettings; use object_store::memory::InMemory; use tokio::fs; use tokio::time::{sleep, timeout}; @@ -73,7 +73,7 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result Result, pub workflow_path: Option, @@ -59,7 +58,7 @@ pub struct CreatedRun { } struct PersistCreateOptions { - settings: SettingsLayer, + settings: WorkflowSettings, run_id: Option, run_dir: Option, workflow_slug: Option, @@ -84,12 +83,11 @@ pub async fn create( cwd: request.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; - - if fabro_config::resolve_run_from_file(&resolved.settings) - .map_or(true, |settings| settings.execution.mode != RunMode::DryRun) - { - validate_sandbox_provider(&resolved.settings)?; + if resolved.settings.run.execution.mode != RunMode::DryRun { + validate_sandbox_provider(&resolved.settings.run)?; } + let labels = resolved.settings.combined_labels(); + let settings = resolved.settings.clone(); let CreateRunInput { workflow: _, @@ -107,9 +105,6 @@ pub async fn create( configured_providers, } = request; - let settings = resolved.settings.clone(); - let resolved_settings = WorkflowSettings::from_layer(&settings) - .map_err(|errors| Error::Precondition(fabro_config::render_resolve_errors(&errors)))?; let run_id = run_id.unwrap_or_else(RunId::new); let storage = Storage::new(storage_root); let run_dir = storage.run_scratch(&run_id).root().to_path_buf(); @@ -150,7 +145,7 @@ pub async fn create( run_id: Some(run_id), run_dir: Some(persisted_run_dir), workflow_slug: workflow_slug.or(resolved_workflow_slug), - labels: resolved_settings.combined_labels(), + labels, base_branch, working_directory, host_repo_path, @@ -271,11 +266,8 @@ fn store_error(err: impl std::fmt::Display) -> Error { Error::engine(err.to_string()) } -fn validate_sandbox_provider(settings: &SettingsLayer) -> Result<(), Error> { - let resolved = fabro_config::resolve_run_from_file(settings) - .map_err(|errors| Error::Precondition(fabro_config::render_resolve_errors(&errors)))?; - resolved - .sandbox +fn validate_sandbox_provider(run: &RunNamespace) -> Result<(), Error> { + run.sandbox .provider .parse::() .map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))?; @@ -313,7 +305,7 @@ pub(super) fn preprocess_and_validate( current_dir: Option, file_resolver: Option>, custom_transforms: Vec>, - settings: Option<&SettingsLayer>, + settings: Option<&WorkflowSettings>, goal_override: Option<&str>, ) -> Result { let inputs = run_inputs(settings); @@ -337,11 +329,9 @@ pub(super) fn preprocess_and_validate( Ok(pipeline::validate(transformed, &[])) } -fn run_inputs(settings: Option<&SettingsLayer>) -> HashMap { +fn run_inputs(settings: Option<&WorkflowSettings>) -> HashMap { settings - .and_then(|settings| settings.run.as_ref()) - .and_then(|run| run.inputs.as_ref()) - .cloned() + .map(|settings| settings.run.inputs.clone()) .unwrap_or_default() } @@ -416,10 +406,15 @@ mod tests { use std::time::Duration; use chrono::{Local, TimeZone, Utc}; + use fabro_config::{ + ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPullRequestLayer, + WorkflowSettingsBuilder, + }; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; - use fabro_types::fixtures; use fabro_types::settings::InterpString; + use fabro_types::settings::run::RunMode; + use fabro_types::{WorkflowSettings, fixtures}; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; @@ -435,11 +430,20 @@ mod tests { )) } - fn test_default_settings() -> SettingsLayer { - SettingsLayer::test_default() + fn settings_from_run_layer(run: RunLayer) -> WorkflowSettings { + WorkflowSettingsBuilder::new() + .run_overrides(run) + .build() + .expect("settings should resolve") } - fn validate_dot(dot_source: &str, settings: SettingsLayer) -> Validated { + fn test_default_settings() -> WorkflowSettings { + WorkflowSettingsBuilder::new() + .build() + .expect("default settings should resolve") + } + + fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { validate(ValidateInput { workflow: WorkflowInput::DotSource { source: dot_source.to_string(), @@ -461,7 +465,7 @@ mod tests { #[test] fn validate_minimal() { - let validated = validate_dot(MINIMAL_DOT, SettingsLayer::default()); + let validated = validate_dot(MINIMAL_DOT, WorkflowSettings::default()); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().name, "Test"); @@ -478,7 +482,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, SettingsLayer::default()); + let validated = validate_dot(dot, WorkflowSettings::default()); validated.raise_on_errors().unwrap(); let prompt = validated.graph().nodes["work"] @@ -516,7 +520,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, SettingsLayer::default()); + let validated = validate_dot(dot, WorkflowSettings::default()); validated.raise_on_errors().unwrap(); assert_eq!( @@ -534,19 +538,18 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - 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())); - SettingsLayer { - run: Some(RunLayer { + let validated = validate_dot( + dot, + settings_from_run_layer({ + let mut inputs = std::collections::HashMap::new(); + inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); + RunLayer { goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), inputs: Some(inputs), ..RunLayer::default() - }), - ..SettingsLayer::default() - } - }); + } + }), + ); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().goal(), "override"); @@ -565,7 +568,7 @@ mod tests { source: "not a graph".to_string(), base_dir: None, }, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }); @@ -578,7 +581,7 @@ mod tests { graph [goal="Test"] work [label="Work"] }"#; - let validated = validate_dot(dot, SettingsLayer::default()); + let validated = validate_dot(dot, WorkflowSettings::default()); assert!(validated.has_errors()); assert!(validated.raise_on_errors().is_err()); @@ -608,7 +611,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), cwd: PathBuf::from("."), custom_transforms: vec![Box::new(TagTransform)], }) @@ -641,7 +644,7 @@ mod tests { let validated = validate(ValidateInput { workflow: WorkflowInput::Path(dot_path), - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), cwd: dir.path().to_path_buf(), custom_transforms: Vec::new(), }) @@ -680,7 +683,7 @@ mod tests { ), ]), }), - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }) @@ -751,25 +754,10 @@ mod tests { base_dir: None, }, settings: { - use fabro_types::settings::run::{ - RunExecutionLayer, RunLayer, RunMode, RunSandboxLayer, - }; - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - sandbox: Some(RunSandboxLayer { - provider: Some("not-a-provider".to_string()), - ..RunSandboxLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + let mut settings = WorkflowSettings::default(); + settings.run.execution.mode = RunMode::Normal; + settings.run.sandbox.provider = "not-a-provider".to_string(); + settings }, cwd: dir.path().to_path_buf(), workflow_slug: None, @@ -790,7 +778,7 @@ mod tests { match err { Error::Precondition(message) => { - assert!(message.contains("run.sandbox.provider")); + assert!(message.contains("Invalid sandbox provider")); assert!(!message.contains('\n')); } other => panic!("expected Precondition, got {other:?}"), @@ -809,37 +797,27 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: { - use fabro_types::settings::ReplaceMap; - use fabro_types::settings::run::{ - RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, - RunPullRequestLayer, - }; + settings: settings_from_run_layer({ let mut metadata = HashMap::new(); metadata.insert("env".to_string(), "test".to_string()); - let mut layer = SettingsLayer { - run: Some(RunLayer { - goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), - metadata: ReplaceMap::from(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() + RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), + metadata: ReplaceMap::from(metadata), + model: Some(RunModelLayer { + name: Some(InterpString::parse("sonnet")), + ..RunModelLayer::default() }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer - }, + pull_request: Some(RunPullRequestLayer { + enabled: Some(false), + ..RunPullRequestLayer::default() + }), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + } + }), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, @@ -860,8 +838,11 @@ mod tests { assert_eq!(created.run_id, fixtures::RUN_1); assert_eq!(created.persisted.run_spec().graph.goal(), "override goal"); assert_eq!( - fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings) - .unwrap() + created + .persisted + .run_spec() + .settings + .run .model .name .as_ref() @@ -870,8 +851,11 @@ mod tests { Some("claude-sonnet-4-6") ); assert_eq!( - fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings) - .unwrap() + created + .persisted + .run_spec() + .settings + .run .model .provider .as_ref() @@ -880,10 +864,7 @@ mod tests { Some("anthropic") ); assert_eq!( - match fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings) - .unwrap() - .goal - { + match &created.persisted.run_spec().settings.run.goal { Some(fabro_types::settings::run::RunGoal::Inline(value)) => { Some(value.as_source()) } @@ -893,8 +874,11 @@ mod tests { Some("override goal") ); assert!( - fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings) - .unwrap() + created + .persisted + .run_spec() + .settings + .run .pull_request .is_none() ); @@ -932,22 +916,16 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let mut layer = SettingsLayer { - run: Some(RunLayer { - working_dir: Some(InterpString::parse("workspace")), - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() + settings: settings_from_run_layer({ + RunLayer { + working_dir: Some(InterpString::parse("workspace")), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer - }, + ..RunLayer::default() + } + }), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -1015,43 +993,24 @@ mod tests { ); } - fn dry_run_only_settings() -> SettingsLayer { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() + fn dry_run_only_settings() -> WorkflowSettings { + settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + ..RunLayer::default() + }) } - fn dry_run_with_storage(storage_dir: &Path) -> SettingsLayer { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() + fn dry_run_with_storage(_storage_dir: &Path) -> WorkflowSettings { + settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() }), - server: Some(ServerLayer { - storage: Some(ServerStorageLayer { - root: Some(InterpString::parse(&storage_dir.to_string_lossy())), - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + ..RunLayer::default() + }) } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 3b6116ed3..003114363 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -164,7 +164,7 @@ mod tests { use std::str::FromStr; use fabro_store::RunProjection; - use fabro_types::RunId; + use fabro_types::{RunId, WorkflowSettings}; use git2::Oid; use super::super::test_support::*; @@ -177,11 +177,12 @@ mod tests { fn make_run_projection(run_id: &RunId) -> RunProjection { let mut projection = RunProjection::default(); + let settings = serde_json::to_value(WorkflowSettings::default()).unwrap(); projection.spec = Some( serde_json::from_value(serde_json::json!({ "run_id": run_id.to_string(), "created_at": "2025-01-01T00:00:00Z", - "settings": {}, + "settings": settings, "graph": { "name": "test_workflow", "nodes": { diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 29970863b..7f66c797b 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -310,8 +310,7 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::Graph; use fabro_store::{Database, RunProjection, StageId}; - use fabro_types::settings::SettingsLayer; - use fabro_types::{RunId, RunSpec, SandboxRecord, StartRecord, fixtures}; + use fabro_types::{RunId, RunSpec, SandboxRecord, StartRecord, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -343,7 +342,7 @@ mod tests { fn sample_run_spec(run_id: RunId, host_repo_path: Option<&str>) -> RunSpec { RunSpec { run_id, - settings: SettingsLayer::default(), + settings: WorkflowSettings::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 390109d1a..051818597 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -7,9 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; -use fabro_config::project as project_config; -use fabro_config::run::resolve_run_goal; -use fabro_types::settings::SettingsLayer; +use fabro_config::project::{resolve_workflow_path, resolve_working_directory_from_run}; +use fabro_config::run::resolve_run_goal_from_namespace; +use fabro_types::WorkflowSettings; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -27,14 +27,14 @@ pub enum WorkflowInput { #[derive(Clone, Debug)] pub(crate) struct ResolveWorkflowInput { pub workflow: WorkflowInput, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub cwd: PathBuf, } #[derive(Clone)] pub(crate) struct ResolvedWorkflow { pub raw_source: String, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, @@ -65,12 +65,11 @@ fn workflow_slug_from_path(workflow_path: &Path) -> Option { pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result { match request.workflow { WorkflowInput::Path(workflow_path) => { - let resolution = project_config::resolve_workflow_path(&workflow_path, &request.cwd)?; + let resolution = resolve_workflow_path(&workflow_path, &request.cwd)?; let settings = request.settings; - let working_directory = - 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 working_directory = resolve_working_directory_from_run(&settings.run, &request.cwd); let goal_override = resolve_goal_override(&settings, &working_directory)?; let current_dir = resolution .dot_path @@ -94,8 +93,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::DotSource { source, base_dir } => { let settings = request.settings; - let working_directory = - project_config::resolve_working_directory(&settings, &request.cwd); + let working_directory = resolve_working_directory_from_run(&settings.run, &request.cwd); let goal_override = resolve_goal_override(&settings, &working_directory)?; let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { @@ -116,8 +114,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::Bundled(workflow) => { let settings = request.settings; - let working_directory = - project_config::resolve_working_directory(&settings, &request.cwd); + let working_directory = resolve_working_directory_from_run(&settings.run, &request.cwd); let goal_override = resolve_goal_override(&settings, &working_directory)?; Ok(ResolvedWorkflow { @@ -140,10 +137,10 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< /// Relative paths that survived config load (e.g. env-interpolated ones) /// are anchored at `working_directory`. fn resolve_goal_override( - settings: &SettingsLayer, + settings: &WorkflowSettings, working_directory: &Path, ) -> anyhow::Result> { - resolve_run_goal(settings, working_directory) + resolve_run_goal_from_namespace(&settings.run, working_directory) .map(|opt| opt.map(|resolved| resolved.text)) .map_err(anyhow::Error::from) } @@ -155,7 +152,7 @@ mod tests { #[test] fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() { use fabro_types::settings::InterpString; - use fabro_types::settings::run::RunLayer; + use fabro_types::settings::run::RunNamespace; let dir = tempfile::tempdir().unwrap(); let resolved = resolve_workflow(ResolveWorkflowInput { @@ -163,12 +160,12 @@ mod tests { source: "digraph Test { start -> exit }".to_string(), base_dir: None, }, - settings: SettingsLayer { - run: Some(RunLayer { + settings: WorkflowSettings { + run: RunNamespace { working_dir: Some(InterpString::parse("workspace")), - ..RunLayer::default() - }), - ..SettingsLayer::default() + ..RunNamespace::default() + }, + ..WorkflowSettings::default() }, cwd: dir.path().to_path_buf(), }) @@ -176,4 +173,49 @@ mod tests { assert_eq!(resolved.working_directory, dir.path().join("workspace")); } + + #[test] + fn resolve_workflow_reads_goal_override_from_dense_run_settings() { + use fabro_types::settings::InterpString; + use fabro_types::settings::run::{RunGoal, RunNamespace}; + + let dir = tempfile::tempdir().unwrap(); + let goal_path = dir.path().join("goal.md"); + std::fs::write(&goal_path, "dense goal").unwrap(); + let resolved = resolve_workflow(ResolveWorkflowInput { + workflow: WorkflowInput::DotSource { + source: "digraph Test { start -> exit }".to_string(), + base_dir: None, + }, + settings: WorkflowSettings { + run: RunNamespace { + goal: Some(RunGoal::File(InterpString::parse( + &goal_path.display().to_string(), + ))), + ..RunNamespace::default() + }, + ..WorkflowSettings::default() + }, + cwd: dir.path().to_path_buf(), + }) + .unwrap(); + + assert_eq!(resolved.goal_override.as_deref(), Some("dense goal")); + } + + #[test] + fn resolve_workflow_uses_dense_settings_without_re_resolution() { + let dir = tempfile::tempdir().unwrap(); + let resolved = resolve_workflow(ResolveWorkflowInput { + workflow: WorkflowInput::DotSource { + source: "digraph Test { start -> exit }".to_string(), + base_dir: None, + }, + settings: WorkflowSettings::default(), + cwd: dir.path().to_path_buf(), + }) + .unwrap(); + + assert_eq!(resolved.settings, WorkflowSettings::default()); + } } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 3a5df431f..ff21b2e8f 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -309,10 +309,9 @@ impl RunSession { let (origin_url, detected_base_branch) = detect_repo_info(&working_directory) .map_or((None, None), |(url, branch)| (Some(url), branch)); - let resolved = fabro_config::resolve_run_from_file(settings) - .map_err(|errors| Error::Precondition(fabro_config::render_resolve_errors(&errors)))?; + let resolved = &settings.run; - let sandbox_provider = resolve_sandbox_provider(&resolved)?; + let sandbox_provider = resolve_sandbox_provider(resolved)?; let sandbox_provider = if resolved.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { SandboxProvider::Local @@ -367,7 +366,7 @@ impl RunSession { None => None, }; SandboxSpec::Daytona { - config: resolve_daytona_config(&resolved).unwrap_or_default(), + config: resolve_daytona_config(resolved).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()), @@ -435,7 +434,7 @@ impl RunSession { artifact_sink: services.artifact_sink, git, github_app: services.github_app.clone(), - worktree_mode: Some(resolve_worktree_mode(&resolved)), + worktree_mode: Some(resolve_worktree_mode(resolved)), registry_override: services.registry_override, retro_enabled: resolved.execution.retros && project_config::is_retro_enabled(), preserve_sandbox: resolved.sandbox.preserve, @@ -970,10 +969,10 @@ mod tests { use std::time::Duration; use chrono::Utc; + use fabro_config::{RunExecutionLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_store::Database; - use fabro_types::fixtures; - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::RunMode; + use fabro_types::{WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -1012,6 +1011,13 @@ mod tests { (storage_root, run_dir) } + fn settings_from_run_layer(run: RunLayer) -> WorkflowSettings { + WorkflowSettingsBuilder::new() + .run_overrides(run) + .build() + .expect("settings should resolve") + } + async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc) { let store = memory_store(); let created = crate::operations::create( @@ -1021,20 +1027,13 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: { - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer - }, + settings: settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), cwd: storage_root .parent() .unwrap_or_else(|| Path::new(".")) @@ -1209,20 +1208,13 @@ mod tests { .unwrap() .clone(), ), - settings: { - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer - }, + settings: settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), cwd: temp.path().to_path_buf(), workflow_slug: Some("bundle-child".to_string()), workflow_path: Some(PathBuf::from("workflow.fabro")), diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index 830076609..e3b5ce0d5 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::SettingsLayer; +use fabro_types::WorkflowSettings; use super::create::preprocess_and_validate; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; @@ -10,7 +10,7 @@ use crate::transforms::Transform; pub struct ValidateInput { pub workflow: WorkflowInput, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, 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 f279d8fca..7df826cf3 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -17,8 +17,7 @@ use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; -use fabro_types::settings::SettingsLayer; -use fabro_types::{RunId, fixtures}; +use fabro_types::{RunId, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -94,7 +93,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), git: None, host_repo_path: None, labels: HashMap::new(), @@ -136,7 +135,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI run_dir.to_path_buf(), RunSpec { run_id, - settings: SettingsLayer::default(), + settings: WorkflowSettings::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 d1065ee74..df87ec021 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -431,8 +431,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; - use fabro_types::{RunId, fixtures}; + use fabro_types::{RunId, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -446,7 +445,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::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 82d53bf82..a22fc3a6f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -599,16 +599,10 @@ pub async fn initialize( .await? }; if effective_dry_run { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::RunMode; options.dry_run = 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); + options.run_options.settings.run.execution.mode = RunMode::DryRun; } let has_run_branch = options @@ -721,13 +715,7 @@ pub async fn initialize( Ok(Initialized { graph, source, - inputs: options - .run_options - .settings - .run - .as_ref() - .and_then(|run| run.inputs.clone()) - .unwrap_or_default(), + inputs: options.run_options.settings.run.inputs.clone(), run_options: options.run_options, workflow_path: options.workflow_path, workflow_bundle: options.workflow_bundle, @@ -761,8 +749,7 @@ mod tests { use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; - use fabro_types::{RunId, fixtures}; + use fabro_types::{RunId, WorkflowSettings, fixtures}; use fabro_vault::{SecretType, Vault}; use object_store::memory::InMemory; use tokio::sync::RwLock as AsyncRwLock; @@ -844,7 +831,7 @@ mod tests { fn test_settings(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), @@ -866,7 +853,7 @@ mod tests { run_dir.to_path_buf(), RunSpec { run_id: test_run_id(), - settings: SettingsLayer::default(), + settings: WorkflowSettings::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 db513c7d8..604bd05a4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -63,9 +63,6 @@ mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_store::{Database, RunDatabase}; use fabro_types::fixtures; - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use super::*; @@ -128,22 +125,15 @@ mod tests { fn sample_record(graph: Graph) -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer { - 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() - }), - ..SettingsLayer::default() + settings: fabro_types::WorkflowSettings { + run: fabro_types::settings::RunNamespace { + execution: fabro_types::settings::run::RunExecutionSettings { + mode: fabro_types::settings::run::RunMode::DryRun, + ..fabro_types::settings::run::RunExecutionSettings::default() + }, + ..fabro_types::settings::RunNamespace::default() + }, + ..fabro_types::WorkflowSettings::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 aca6e0d48..84f050f29 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -598,7 +598,6 @@ mod tests { AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro, }; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; use fabro_types::{BilledTokenCounts, RunSpec, SuccessReason, fixtures}; use futures::stream; use object_store::memory::InMemory; @@ -1090,7 +1089,7 @@ mod tests { let run_spec = RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: fabro_types::WorkflowSettings::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1155,7 +1154,7 @@ mod tests { let run_spec = RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: fabro_types::WorkflowSettings::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1374,7 +1373,7 @@ mod tests { let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let run_spec = RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: fabro_types::WorkflowSettings::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 74dd181a2..78f689486 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -184,8 +184,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; - use fabro_types::{RunId, fixtures}; + use fabro_types::{RunId, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -234,7 +233,7 @@ mod tests { let run_store = inner; let run_spec = RunSpec { run_id: test_run_id(), - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: run_dir.to_path_buf(), @@ -297,7 +296,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), diff --git a/lib/crates/fabro-workflow/src/run_dump.rs b/lib/crates/fabro-workflow/src/run_dump.rs index c4dd6c941..dbca35622 100644 --- a/lib/crates/fabro-workflow/src/run_dump.rs +++ b/lib/crates/fabro-workflow/src/run_dump.rs @@ -427,10 +427,9 @@ mod tests { use fabro_store::{NodeState, RunProjection, StageId}; use fabro_types::graph::Graph; use fabro_types::run::RunSpec; - use fabro_types::settings::SettingsLayer; use fabro_types::{ Checkpoint, Conclusion, NodeStatusRecord, RunStatus, SandboxRecord, StageStatus, - StartRecord, SuccessReason, fixtures, + StartRecord, SuccessReason, WorkflowSettings, fixtures, }; use super::RunDump; @@ -439,7 +438,7 @@ mod tests { fn sample_run_spec() -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph: Graph::new("ship"), workflow_slug: Some("demo".to_string()), working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 0e832624a..1421ddea8 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -402,8 +402,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; - use fabro_types::{RunStatus, fixtures}; + use fabro_types::{RunStatus, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::scan_runs_combined; @@ -423,7 +422,7 @@ mod tests { fn sample_run_spec() -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::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_materialization.rs b/lib/crates/fabro-workflow/src/run_materialization.rs index 52054327f..62806c3e6 100644 --- a/lib/crates/fabro-workflow/src/run_materialization.rs +++ b/lib/crates/fabro-workflow/src/run_materialization.rs @@ -1,25 +1,26 @@ use fabro_graphviz::graph::Graph; use fabro_model::{Catalog, Provider}; -use fabro_types::settings::run::{RunGoalLayer, RunLayer, RunModelLayer}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::WorkflowSettings; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::RunGoal; pub fn materialize_run( - mut layer: SettingsLayer, + mut settings: WorkflowSettings, graph: &Graph, catalog: &Catalog, configured_providers: &[Provider], -) -> SettingsLayer { - let configured_model = layer +) -> WorkflowSettings { + let configured_model = settings .run + .model + .name .as_ref() - .and_then(|run| run.model.as_ref()) - .and_then(|model| model.name.as_ref()) .map(InterpString::as_source); - let configured_provider = layer + let configured_provider = settings .run + .model + .provider .as_ref() - .and_then(|run| run.model.as_ref()) - .and_then(|model| model.provider.as_ref()) .map(InterpString::as_source); let graph_provider = graph .attrs @@ -51,25 +52,24 @@ pub fn materialize_run( None => (model, provider), }; - let run = layer.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); + settings.run.model.name = Some(InterpString::parse(&resolved_model)); + settings.run.model.provider = resolved_provider.as_deref().map(InterpString::parse); let goal = graph.goal().to_string(); - run.goal = if goal.is_empty() { + settings.run.goal = if goal.is_empty() { None } else { - Some(RunGoalLayer::Inline(InterpString::parse(&goal))) + Some(RunGoal::Inline(InterpString::parse(&goal))) }; - if run + if settings + .run .pull_request .as_ref() - .is_some_and(|pull_request| !pull_request.enabled.unwrap_or(false)) + .is_some_and(|pull_request| !pull_request.enabled) { - run.pull_request = None; + settings.run.pull_request = None; } - layer + settings } diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index 28057beb1..5ee57fb05 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -3,9 +3,8 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use fabro_types::RunId; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::RunMode; +use fabro_types::{RunId, WorkflowSettings}; use crate::git::{GitAuthor, git_author_from_settings}; @@ -20,7 +19,7 @@ pub struct GitCheckpointOptions { /// Options for a workflow run. #[derive(Clone)] pub struct RunOptions { - pub settings: SettingsLayer, + pub settings: WorkflowSettings, pub run_dir: PathBuf, pub cancel_token: Option>, /// Unique identifier for this workflow run. @@ -44,14 +43,11 @@ pub struct RunOptions { impl RunOptions { pub fn dry_run_enabled(&self) -> bool { - fabro_config::resolve_run_from_file(&self.settings) - .is_ok_and(|settings| settings.execution.mode == RunMode::DryRun) + self.settings.run.execution.mode == RunMode::DryRun } pub fn checkpoint_exclude_globs(&self) -> Vec { - fabro_config::resolve_run_from_file(&self.settings) - .map(|settings| settings.checkpoint.exclude_globs) - .unwrap_or_default() + self.settings.run.checkpoint.exclude_globs.clone() } pub fn git_author(&self) -> GitAuthor { @@ -59,9 +55,7 @@ impl RunOptions { } pub fn artifact_globs(&self) -> Vec { - fabro_config::resolve_run_from_file(&self.settings) - .map(|settings| settings.artifacts.include) - .unwrap_or_default() + self.settings.run.artifacts.include.clone() } } diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index 1b15c7fe2..bdec544fc 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -112,8 +112,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; use fabro_types::run_event::RunSubmittedProps; - use fabro_types::settings::SettingsLayer; - use fabro_types::{EventBody, RunEvent, fixtures}; + use fabro_types::{EventBody, RunEvent, WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::RunStoreHandle; @@ -133,7 +132,7 @@ mod tests { fn test_run_spec() -> RunSpec { RunSpec { run_id: fixtures::RUN_1, - settings: SettingsLayer::default(), + settings: WorkflowSettings::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 a7b569e4b..62b28808a 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -145,12 +145,7 @@ async fn initialized( initialized: Initialized { graph: graph.clone(), source: String::new(), - inputs: run_options - .settings - .run - .as_ref() - .and_then(|run| run.inputs.clone()) - .unwrap_or_default(), + inputs: run_options.settings.run.inputs.clone(), run_options: run_options.clone(), workflow_path: None, workflow_bundle: None, diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 595b46ca4..66a4638e0 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -27,9 +27,7 @@ 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::settings::SettingsLayer; -use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; -use fabro_types::{RunId, StageId}; +use fabro_types::{RunId, StageId, WorkflowSettings}; use fabro_workflow::artifact::sync_artifacts_to_env; use fabro_workflow::context::Context; use fabro_workflow::error::Error; @@ -511,7 +509,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -689,7 +687,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("git-cp-test"), @@ -860,7 +858,7 @@ async fn daytona_parallel_git_branching_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env)); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_tmp.path().to_path_buf(), cancel_token: None, run_id, @@ -1213,7 +1211,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, @@ -1347,14 +1345,14 @@ async fn daytona_asset_collection() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: SettingsLayer { - run: Some(RunLayer { - artifacts: Some(RunArtifactsLayer { + settings: WorkflowSettings { + run: fabro_types::settings::RunNamespace { + artifacts: fabro_types::settings::run::ArtifactsSettings { include: vec!["test-results/**".to_string()], - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + }, + ..fabro_types::settings::RunNamespace::default() + }, + ..WorkflowSettings::default() }, run_dir: dir.path().to_path_buf(), cancel_token: None, @@ -1614,7 +1612,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, diff --git a/lib/crates/fabro-workflow/tests/it/git_integration.rs b/lib/crates/fabro-workflow/tests/it/git_integration.rs index caf03567a..ad09a6af9 100644 --- a/lib/crates/fabro-workflow/tests/it/git_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/git_integration.rs @@ -10,8 +10,7 @@ use std::sync::Arc; use fabro_agent::Sandbox; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; -use fabro_types::settings::SettingsLayer; -use fabro_types::{RunEvent, fixtures}; +use fabro_types::{RunEvent, WorkflowSettings, fixtures}; use fabro_workflow::event::Emitter; use fabro_workflow::git::{ MetadataStore, add_worktree, branch_needs_push, create_branch, push_branch, push_ref, @@ -157,7 +156,7 @@ fn test_run_options(run_dir: &Path) -> RunOptions { run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: fixtures::RUN_2, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), git: None, host_repo_path: None, labels: HashMap::new(), diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 9c174ea7a..d21ae0a95 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -32,9 +32,7 @@ use fabro_interview::{ }; use fabro_llm::provider::Provider; use fabro_store::{ArtifactStore, Database}; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; -use fabro_types::{RunEvent, RunId, StageId}; +use fabro_types::{RunEvent, RunId, StageId, WorkflowSettings}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{Error, FailureSignatureExt}; @@ -343,7 +341,7 @@ async fn end_to_end_linear_pipeline() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -472,7 +470,7 @@ async fn end_to_end_branching_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -591,7 +589,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -686,7 +684,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -796,7 +794,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -909,7 +907,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1029,7 +1027,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1343,7 +1341,7 @@ async fn retry_on_failure_then_succeed() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1417,7 +1415,7 @@ async fn pipeline_with_many_nodes() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1763,7 +1761,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1864,7 +1862,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1976,7 +1974,7 @@ async fn resume_from_checkpoint_completes_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2074,7 +2072,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2116,7 +2114,7 @@ async fn graph_goal_in_context() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2154,7 +2152,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2233,7 +2231,7 @@ async fn context_flow_between_stages() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2288,7 +2286,7 @@ async fn tool_handler_e2e() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2362,7 +2360,7 @@ async fn auto_approve_interviewer_e2e() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2401,7 +2399,7 @@ async fn codergen_without_backend_simulated() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2505,7 +2503,7 @@ async fn branching_loop_back_on_failure() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2590,7 +2588,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2654,7 +2652,7 @@ async fn scenario_ship_a_feature() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2738,7 +2736,7 @@ async fn scenario_parallel_expert_review() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2824,7 +2822,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2888,7 +2886,7 @@ async fn scenario_loop_restart_resets_context() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2955,7 +2953,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3016,7 +3014,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3125,7 +3123,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3206,7 +3204,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3346,7 +3344,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3401,7 +3399,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3450,7 +3448,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3491,7 +3489,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3551,7 +3549,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3597,7 +3595,7 @@ async fn stylesheet_applies_model_override() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3652,7 +3650,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3729,7 +3727,7 @@ async fn integration_smoke_plan_implement_review_done() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3820,7 +3818,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3953,7 +3951,7 @@ async fn manager_loop_context_flows_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4028,7 +4026,7 @@ async fn manager_loop_child_dotfile_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4131,7 +4129,7 @@ async fn import_e2e_through_engine() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4285,7 +4283,7 @@ async fn fidelity_default_is_compact() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4341,7 +4339,7 @@ async fn fidelity_graph_default_applied() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4393,7 +4391,7 @@ async fn fidelity_node_overrides_graph_default() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4451,7 +4449,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4499,7 +4497,7 @@ async fn fidelity_full_produces_empty_preamble() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4557,7 +4555,7 @@ async fn fidelity_truncate_preamble_minimal() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4628,7 +4626,7 @@ async fn fidelity_summary_low_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4694,7 +4692,7 @@ async fn fidelity_summary_medium_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4760,7 +4758,7 @@ async fn fidelity_summary_high_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4819,7 +4817,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4889,7 +4887,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4969,7 +4967,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5065,7 +5063,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5148,7 +5146,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5189,7 +5187,7 @@ async fn fidelity_stored_in_checkpoint_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5281,7 +5279,7 @@ async fn fidelity_precedence_multi_node_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5348,7 +5346,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5422,7 +5420,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir_low.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5488,7 +5486,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir_med.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5559,7 +5557,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5612,7 +5610,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5668,7 +5666,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5725,7 +5723,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5792,7 +5790,7 @@ async fn fidelity_from_parsed_dot_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5840,7 +5838,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5912,7 +5910,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5998,7 +5996,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6043,7 +6041,7 @@ mod real_llm { use fabro_llm::client::Client; use fabro_llm::providers::OpenAiAdapter; use fabro_llm::types::{Message, Request}; - use fabro_types::settings::SettingsLayer; + use fabro_types::WorkflowSettings; use fabro_workflow::context::Context; use fabro_workflow::error::Error; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; @@ -6234,7 +6232,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6342,7 +6340,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6474,7 +6472,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6574,7 +6572,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6667,7 +6665,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6797,7 +6795,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6913,7 +6911,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7040,7 +7038,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7148,7 +7146,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7450,7 +7448,7 @@ fn engine_with_hooks_and_events( fn make_run_options(dir: &std::path::Path) -> RunOptions { RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.to_path_buf(), cancel_token: None, run_id: test_run_id("hook-test-run"), @@ -8119,9 +8117,9 @@ async fn hook_config_merge_run_overrides_by_name() { } // The legacy `Settings`-based TOML parsing tests were deleted in Stage -// 6.3b. Hook TOML parsing now flows through the v2 `SettingsLayer` path, -// with coverage in `fabro-types::settings::layer::tests` and the -// fabro-cli integration tests under `cmd::config`. +// 6.3b. Hook TOML parsing now flows through the v2 config parser path, +// with coverage in fabro-config unit tests and the fabro-cli integration +// tests under `cmd::config`. // --- Blocking vs non-blocking behavior --- @@ -8388,7 +8386,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8589,7 +8587,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8793,7 +8791,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8880,7 +8878,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8967,7 +8965,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9097,7 +9095,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9967,7 +9965,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10085,7 +10083,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10280,7 +10278,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-docker"), @@ -10447,7 +10445,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id.to_string()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10646,7 +10644,7 @@ async fn parallel_git_branching_host_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10895,7 +10893,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("empty-diff"), @@ -11265,7 +11263,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-circuit-breaker"), @@ -11311,7 +11309,7 @@ async fn e2e_circuit_breaker_custom_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-custom-limit"), @@ -11350,7 +11348,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-transient-no-breaker"), @@ -11396,7 +11394,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-varying-reasons"), @@ -11435,7 +11433,7 @@ async fn e2e_circuit_breaker_loop_restart() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-breaker"), @@ -11497,7 +11495,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-context"), @@ -11560,7 +11558,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-hint"), @@ -11617,7 +11615,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-persist"), @@ -11744,7 +11742,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-events"), @@ -11810,7 +11808,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-below-limit"), @@ -11905,7 +11903,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-impl-verify-cycle"), @@ -12002,7 +12000,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-det"), @@ -12041,7 +12039,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-struct"), @@ -12080,7 +12078,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-budget"), @@ -12119,7 +12117,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-canceled"), @@ -12155,7 +12153,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-comploop"), @@ -12195,7 +12193,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-allowed-transient"), @@ -12302,7 +12300,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-e2e"), @@ -12357,7 +12355,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-alive-e2e"), @@ -12402,7 +12400,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-disabled-e2e"), @@ -12467,7 +12465,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: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-override-e2e"), @@ -12599,14 +12597,14 @@ async fn asset_collection_local_sandbox_success() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: SettingsLayer { - run: Some(RunLayer { - artifacts: Some(RunArtifactsLayer { + settings: WorkflowSettings { + run: fabro_types::settings::RunNamespace { + artifacts: fabro_types::settings::run::ArtifactsSettings { include: vec!["test-results/**".to_string()], - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + }, + ..fabro_types::settings::RunNamespace::default() + }, + ..WorkflowSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12731,14 +12729,14 @@ async fn asset_collection_local_sandbox_on_failure() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: SettingsLayer { - run: Some(RunLayer { - artifacts: Some(RunArtifactsLayer { + settings: WorkflowSettings { + run: fabro_types::settings::RunNamespace { + artifacts: fabro_types::settings::run::ArtifactsSettings { include: vec!["test-results/**".to_string()], - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + }, + ..fabro_types::settings::RunNamespace::default() + }, + ..WorkflowSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12836,14 +12834,14 @@ async fn asset_collection_docker_sandbox() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: SettingsLayer { - run: Some(RunLayer { - artifacts: Some(RunArtifactsLayer { + settings: WorkflowSettings { + run: fabro_types::settings::RunNamespace { + artifacts: fabro_types::settings::run::ArtifactsSettings { include: vec!["test-results/**".to_string()], - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + }, + ..fabro_types::settings::RunNamespace::default() + }, + ..WorkflowSettings::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12912,7 +12910,7 @@ async fn wait_timer_e2e() { local_env(), ); let run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), diff --git a/lib/crates/fabro-workflow/tests/materialize_run.rs b/lib/crates/fabro-workflow/tests/materialize_run.rs index 52486dbce..9c3ff23d1 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/fabro-workflow/tests/materialize_run.rs @@ -1,8 +1,9 @@ use fabro_graphviz::graph::Graph; use fabro_graphviz::parser; use fabro_model::{Catalog, Provider}; -use fabro_types::settings::run::{RunGoalLayer, RunLayer, RunModelLayer, RunPullRequestLayer}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::WorkflowSettings; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace}; use fabro_workflow::run_materialization::materialize_run; fn graph(source: &str) -> Graph { @@ -18,23 +19,23 @@ fn materialize_run_applies_graph_and_catalog_defaults() { start -> exit }"#; - let settings = SettingsLayer { - run: Some(RunLayer { - model: Some(RunModelLayer { + let settings = WorkflowSettings { + run: RunNamespace { + model: RunModelSettings { name: Some(InterpString::parse("sonnet")), - ..RunModelLayer::default() + ..RunModelSettings::default() + }, + pull_request: Some(PullRequestSettings { + enabled: false, + ..PullRequestSettings::default() }), - pull_request: Some(RunPullRequestLayer { - enabled: Some(false), - ..RunPullRequestLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + ..RunNamespace::default() + }, + ..WorkflowSettings::default() }; let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[]); - let resolved = fabro_config::resolve_run_from_file(&materialized).unwrap(); + let resolved = &materialized.run; assert_eq!( resolved @@ -55,8 +56,8 @@ fn materialize_run_applies_graph_and_catalog_defaults() { Some("anthropic") ); assert_eq!( - materialized.run.as_ref().and_then(|run| run.goal.as_ref()), - Some(&RunGoalLayer::Inline(InterpString::parse("Build feature"))) + materialized.run.goal.as_ref(), + Some(&RunGoal::Inline(InterpString::parse("Build feature"))) ); assert!(resolved.pull_request.is_none()); } @@ -71,12 +72,12 @@ fn materialize_run_uses_configured_provider_defaults() { }"#; let materialized = materialize_run( - SettingsLayer::default(), + WorkflowSettings::default(), &graph(source), Catalog::builtin(), &[Provider::OpenAi], ); - let resolved = fabro_config::resolve_run_from_file(&materialized).unwrap(); + let resolved = &materialized.run; assert_eq!( resolved 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 9b4c6eb3c..5d14b5113 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 @@ -608,7 +608,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Returns the persisted `SettingsLayer` used to launch this run. + * Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. * @summary Retrieve Run Settings * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -874,7 +874,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns the persisted `SettingsLayer` used to launch this run. + * Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. * @summary Retrieve Run Settings * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1046,7 +1046,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.retrieveRunCheckpoint(id, options).then((request) => request(axios, basePath)); }, /** - * Returns the persisted `SettingsLayer` used to launch this run. + * Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. * @summary Retrieve Run Settings * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1222,7 +1222,7 @@ export class RunInternalsApi extends BaseAPI { } /** - * Returns the persisted `SettingsLayer` used to launch this run. + * Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. * @summary Retrieve Run Settings * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option.