From 44296f233ed90258a77581580dfee8b47d631cbf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 11:02:26 -0400 Subject: [PATCH 01/60] plan --- ...types-boundary-and-dense-migration-plan.md | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 docs/plans/2026-04-23-003-refactor-config-types-boundary-and-dense-migration-plan.md 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..c7caa09e4 --- /dev/null +++ b/docs/plans/2026-04-23-003-refactor-config-types-boundary-and-dense-migration-plan.md @@ -0,0 +1,1008 @@ +--- +title: "refactor: fabro-config types boundary and dense-type migration" +type: refactor +status: active +date: 2026-04-23 +--- + +# Fabro Config: Types Boundary & Dense-Type Migration + +## Overview + +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 sparse `RunSettingsLayer` (today) 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 `RunSettingsLayer` 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 `RunSettingsLayer` 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 `RunSettingsLayer` → `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 assertion `resolved_server.integrations.github.app_id == "snapshotted-app-id"` 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 `RunSettingsLayer` 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. `RunSettingsLayer` 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 `RunSettingsLayer` (sparse). 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 `RunSettingsLayer` 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:** +- `rg "RunSettingsLayer" .` 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** (sparse `RunSettingsLayer` → 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 snapshotted-app-id assertion is gone +rg "snapshotted-app-id" lib/crates/fabro-server/ +# Expected: 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. OpenAPI RunSettingsLayer schema renamed +rg "RunSettingsLayer" . +# Expected: 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 `resolved_server.integrations.github.app_id == "snapshotted-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` From b2bcf0d5a890094aa5f70796a64c0a54cba5c876 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 11:35:21 -0400 Subject: [PATCH 02/60] refactor settings builders and dense run snapshots --- apps/fabro-web/app/routes/run-settings.tsx | 4 +- docs/api-reference/fabro-api.yaml | 10 +- lib/crates/fabro-api/build.rs | 3 +- lib/crates/fabro-api/src/lib.rs | 3 +- .../tests/server_settings_round_trip.rs | 5 +- .../tests/workflow_settings_round_trip.rs | 55 +++ lib/crates/fabro-cli/src/command_context.rs | 5 +- .../fabro-cli/src/commands/config/mod.rs | 5 +- lib/crates/fabro-cli/src/commands/install.rs | 4 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 4 +- .../fabro-cli/src/commands/run/attach.rs | 3 +- .../fabro-cli/src/commands/run/runner.rs | 31 +- lib/crates/fabro-cli/src/local_server.rs | 30 +- lib/crates/fabro-cli/src/user_config.rs | 3 +- lib/crates/fabro-cli/tests/it/cmd/config.rs | 2 +- lib/crates/fabro-config/src/builders.rs | 244 +++++++++++ lib/crates/fabro-config/src/context.rs | 96 ----- lib/crates/fabro-config/src/defaults.rs | 4 +- .../fabro-config/src/effective_settings.rs | 394 ------------------ lib/crates/fabro-config/src/lib.rs | 21 +- lib/crates/fabro-config/src/resolve/mod.rs | 25 +- lib/crates/fabro-config/src/resolve/run.rs | 2 +- lib/crates/fabro-config/tests/defaults.rs | 14 +- lib/crates/fabro-config/tests/resolve_cli.rs | 12 +- lib/crates/fabro-config/tests/resolve_root.rs | 15 +- .../fabro-config/tests/resolve_server.rs | 29 +- lib/crates/fabro-server/src/demo/mod.rs | 88 ++-- lib/crates/fabro-server/src/install.rs | 3 +- lib/crates/fabro-server/src/run_manifest.rs | 55 ++- lib/crates/fabro-server/src/serve.rs | 4 +- lib/crates/fabro-server/src/server.rs | 65 ++- lib/crates/fabro-server/tests/it/api/runs.rs | 26 +- .../tests/it/openapi_conformance.rs | 10 +- lib/crates/fabro-types/src/dense.rs | 46 ++ lib/crates/fabro-types/src/lib.rs | 2 + lib/crates/fabro-types/src/run.rs | 6 +- lib/crates/fabro-types/src/run_event/run.rs | 5 +- lib/crates/fabro-types/src/settings/cli.rs | 18 +- .../fabro-types/src/settings/project.rs | 2 +- lib/crates/fabro-types/src/settings/run.rs | 81 ++-- .../fabro-types/src/settings/workflow.rs | 2 +- lib/crates/fabro-workflow/src/git.rs | 12 +- .../src/handler/manager_loop.rs | 3 +- .../fabro-workflow/src/operations/create.rs | 45 +- .../fabro-workflow/src/operations/start.rs | 3 +- .../fabro-workflow/src/pipeline/initialize.rs | 15 +- lib/crates/fabro-workflow/src/run_options.rs | 16 +- lib/crates/fabro-workflow/src/test_support.rs | 5 +- .../src/api/run-internals-api.ts | 8 +- 49 files changed, 704 insertions(+), 839 deletions(-) create mode 100644 lib/crates/fabro-api/tests/workflow_settings_round_trip.rs create mode 100644 lib/crates/fabro-config/src/builders.rs delete mode 100644 lib/crates/fabro-config/src/context.rs delete mode 100644 lib/crates/fabro-config/src/effective_settings.rs create mode 100644 lib/crates/fabro-types/src/dense.rs 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/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index bf2392b5d..7ce643dd0 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -1376,7 +1376,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: @@ -1385,7 +1385,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RunSettingsLayer" + $ref: "#/components/schemas/WorkflowSettings" "404": description: Run not found content: @@ -5445,10 +5445,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/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index e52a0d83a..b76b90673 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..1f0858261 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -14,7 +14,8 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } pub mod types { - pub use fabro_config::ServerSettings; + pub use fabro_types::WorkflowSettings; + pub use fabro_types::ServerSettings; pub use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, 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..ab8ac291b 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, parse_settings_layer}; +use fabro_types::ServerSettings; use fabro_types::settings::server::ObjectStoreSettings; use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; @@ -54,7 +55,7 @@ session_sandboxes = true "#, ) .expect("settings fixture should parse"); - let settings = ServerSettings::from_layer(&layer).expect("settings should resolve"); + let settings = ServerSettingsBuilder::from_layer(&layer).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..0f2c50c6c --- /dev/null +++ b/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs @@ -0,0 +1,55 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::WorkflowSettings as ApiWorkflowSettings; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_types::WorkflowSettings; + +#[test] +fn workflow_settings_family_reuses_domain_types() { + assert_same_type::(); +} + +#[test] +fn workflow_settings_json_matches_openapi_shape() { + let layer = parse_settings_layer( + r#" +_version = 1 + +[project] +directory = "workspace" + +[workflow] +name = "Ship" +graph = "ship.fabro" + +[run] +goal = "Ship it" + +[run.execution] +approval = "auto" +"#, + ) + .expect("settings fixture should parse"); + let settings = WorkflowSettingsBuilder::from_layer(&layer).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-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index ee215b554..f4895d763 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_config::UserSettingsBuilder; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::UserSettings; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -177,7 +178,7 @@ fn merge_settings_layer( ..SettingsLayer::default() } .combine(disk_settings); - let user_settings = UserSettings::from_layer(&machine_settings)?; + let user_settings = UserSettingsBuilder::from_layer(&machine_settings)?; Ok((machine_settings, user_settings)) } 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/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 60c4b016b..05ec460ad 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1266,7 +1266,7 @@ async fn write_artifact_store_metadata( settings: &SettingsLayer, fabro_version: &str, ) -> Result<()> { - let resolved = fabro_config::ServerSettings::from_layer(settings)?; + let resolved = fabro_config::ServerSettingsBuilder::from_layer(settings)?; let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(fabro_version).await?; @@ -1782,7 +1782,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( .context("failed to parse generated settings.toml")?, args.storage_dir.as_deref(), ); - fabro_config::ServerSettings::from_layer(&install_settings)?; + fabro_config::ServerSettingsBuilder::from_layer(&install_settings)?; // Secrets and auth material { diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index d5fd6c7e7..db8e2d4f8 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -5,7 +5,7 @@ mod merge; mod view; use anyhow::{Context, Result, anyhow}; -use fabro_config::Storage; +use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_github::GitHubCredentials; use fabro_types::PullRequestRecord; use fabro_types::settings::InterpString; @@ -33,7 +33,7 @@ pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Resu reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] fn load_github_credentials_required(base_ctx: &CommandContext) -> Result { - let server_settings = fabro_config::ServerSettings::from_layer(base_ctx.machine_settings()) + let server_settings = ServerSettingsBuilder::from_layer(base_ctx.machine_settings()) .map_err(anyhow::Error::from)?; let vault = user_config::storage_dir(base_ctx.machine_settings()) .ok() diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 712f75b2d..350b7fcd5 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -85,8 +85,7 @@ pub(crate) async fn attach_run_with_client( ) -> 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) + record.settings.run.execution.approval == ApprovalMode::Auto }); let events = client.list_run_events(run_id, None, None).await?; let replay_events = events.clone(); diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index b587fb3a1..45a747a5e 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -13,12 +13,14 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; -use fabro_config::Storage; +use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer}; use fabro_types::settings::run::RunMode; -use fabro_types::settings::{InterpString, SettingsLayer}; -use fabro_types::{ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId}; +use fabro_types::settings::InterpString; +use fabro_types::{ + ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId, WorkflowSettings, +}; use fabro_vault::Vault; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; use fabro_workflow::event::{Emitter, RunEventSink}; @@ -503,26 +505,25 @@ fn update_worker_title_from_event(event: &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 + 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.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() - }); + .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/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 72bcfc576..88f2288b6 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -8,8 +8,9 @@ use std::path::PathBuf; use anyhow::Result; use fabro_config::bind::BindRequest; -use fabro_server::serve::resolve_bind_request_from_settings; +use fabro_config::ServerSettingsBuilder; use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +use fabro_types::ServerSettings; pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) @@ -19,7 +20,16 @@ pub(crate) fn storage_dir_with_lookup( settings: &SettingsLayer, lookup: &dyn Fn(&str) -> Option, ) -> Result { - let storage_root = fabro_config::resolve_storage_root(settings); + let storage_root = settings + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.clone()) + .unwrap_or_else(|| { + fabro_types::settings::InterpString::parse( + &fabro_config::user::default_storage_dir().to_string_lossy(), + ) + }); let resolved_root = storage_root .resolve(lookup) .map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?; @@ -30,19 +40,21 @@ pub(crate) fn bind_request( settings: &SettingsLayer, cli_override: Option<&str>, ) -> Result { - resolve_bind_request_from_settings(settings, cli_override) + fabro_server::serve::resolve_bind_request_from_settings(settings, cli_override) } pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec { - fabro_config::ServerSettings::from_layer(settings) + resolved_server_settings(settings) .map(|resolved| resolved.server.auth.methods) .unwrap_or_default() } 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()) + resolved_server_settings(settings) + .ok() + .and_then(|settings| settings.server.logging.level) +} + +fn resolved_server_settings(settings: &SettingsLayer) -> Result { + ServerSettingsBuilder::from_layer(settings).map_err(Into::into) } diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 6131c2870..36394143c 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; pub(crate) use fabro_config::user::*; +use fabro_config::UserSettingsBuilder; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::version::FABRO_VERSION; @@ -41,7 +42,7 @@ 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 user_settings = UserSettingsBuilder::from_layer(settings)?; let Some(value) = cli_target_from_settings(&user_settings.cli) else { return Ok(None); }; diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 28dc1db55..a12a1b76d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -74,7 +74,7 @@ shared = "server" } 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_layer(&server_settings_layer_fixture()) .expect("server settings fixture should resolve"); serde_json::to_value(settings).expect("resolved settings payload should serialize") } diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs new file mode 100644 index 000000000..b7479b1eb --- /dev/null +++ b/lib/crates/fabro-config/src/builders.rs @@ -0,0 +1,244 @@ +use std::fmt; +use std::path::Path; + +use fabro_types::settings::{CliLayer, Combine, RunLayer, SettingsLayer}; +use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; + +use crate::load::load_settings_path; +use crate::parse::parse_settings_layer; +use crate::resolve::{ + ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server, + resolve_workflow, +}; +use crate::user::load_settings_config; +use crate::{Error, Result, apply_builtin_defaults}; + +#[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 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 = parse_settings_layer(source) + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + 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); + 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_from(path: &Path) -> Result { + let layer = load_settings_path(path)?; + Self::from_layer(&layer) + } + + pub fn from_toml(source: &str) -> Result { + let layer = parse_settings_layer(source) + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + 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); + finish_result( + UserSettings { cli, features }, + "failed to resolve user settings", + errors, + ) + } +} + +#[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() + } + + #[must_use] + pub fn args_layer(mut self, layer: SettingsLayer) -> Self { + self.args = layer; + self + } + + #[must_use] + pub fn workflow_layer(mut self, layer: SettingsLayer) -> Self { + self.workflow = layer; + self + } + + #[must_use] + pub fn project_layer(mut self, layer: SettingsLayer) -> Self { + self.project = layer; + self + } + + #[must_use] + pub fn user_layer(mut self, layer: SettingsLayer) -> Self { + self.user = layer; + self + } + + #[must_use] + pub fn server_layer(mut self, layer: SettingsLayer) -> Self { + self.server = layer; + self + } + + #[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 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 = apply_builtin_defaults(layer); + layer.server = None; + layer.cli = None; + layer.features = None; + layer + } + + pub fn build(self) -> std::result::Result { + Self::from_layer(&self.build_layer()) + } + + 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); + finish_dense_result( + WorkflowSettings { + project, + workflow, + run, + }, + 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()) + } +} 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..49a282e6d 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -10,11 +10,11 @@ static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { }); #[must_use] -pub fn defaults_layer() -> &'static SettingsLayer { +pub(crate) fn defaults_layer() -> &'static SettingsLayer { &DEFAULTS_LAYER } #[must_use] -pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { +pub(crate) 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/lib.rs b/lib/crates/fabro-config/src/lib.rs index bff64f72f..22f4252a6 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,18 +2,15 @@ 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; pub mod bind; pub mod daemon; -pub mod effective_settings; pub mod envfile; pub mod error; pub mod home; @@ -27,8 +24,10 @@ pub mod user; use std::path::Path; -pub use context::{ServerSettings, UserSettings, WorkflowSettings}; -pub use defaults::{apply_builtin_defaults, defaults_layer}; +pub(crate) use defaults::apply_builtin_defaults; +pub use builders::{ + ResolveErrors, ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder, +}; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; @@ -37,10 +36,10 @@ pub use load::{ }; pub use parse::{ParseError, parse_settings_layer}; 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, dev_token_auth_enabled, 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_workflow, + resolve_workflow_from_file, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 754cba237..91825e748 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -9,8 +9,8 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, - SettingsLayer, WorkflowNamespace, + CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, + ServerNamespace, SettingsLayer, WorkflowNamespace, }; pub use features::resolve_features; pub use project::resolve_project; @@ -19,17 +19,6 @@ pub use server::{dev_token_auth_enabled, 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()); @@ -81,16 +70,6 @@ pub fn resolve_workflow_from_file( 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, diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 8c918685f..ced34eab6 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -451,7 +451,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/tests/defaults.rs b/lib/crates/fabro-config/tests/defaults.rs index dc5aa78c3..853bda4c0 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/tests/defaults.rs @@ -1,8 +1,8 @@ use fabro_config::{ - apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve_run_from_file, - resolve_server_from_file, resolve_workflow_from_file, + parse_settings_layer, resolve_run_from_file, resolve_server_from_file, + resolve_workflow_from_file, }; -use fabro_types::settings::SettingsLayer; +use fabro_types::settings::{Combine, SettingsLayer}; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; use fabro_types::settings::server::ObjectStoreProvider; @@ -11,9 +11,13 @@ fn parse(source: &str) -> SettingsLayer { parse_settings_layer(source).expect("fixture should parse") } +fn embedded_defaults() -> SettingsLayer { + parse(include_str!("../src/defaults.toml")) +} + #[test] fn embedded_defaults_parse_successfully() { - let defaults = defaults_layer(); + let defaults = embedded_defaults(); assert_eq!( defaults @@ -33,7 +37,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 diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs index 01c05b60a..a9a54fad6 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -39,8 +39,8 @@ session_sandboxes = true ) .expect("fixture should parse"); - let user_settings = - fabro_config::UserSettings::from_layer(&settings).expect("user settings should resolve"); + let user_settings = fabro_config::UserSettingsBuilder::from_layer(&settings) + .expect("user settings should resolve"); assert_eq!( user_settings.cli, @@ -71,8 +71,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 +83,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); diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index f0f79c275..4c48315a1 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -107,8 +107,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"); @@ -135,7 +135,7 @@ shared = "run" "#, ); - let labels = fabro_config::WorkflowSettings::from_layer(&settings) + let labels = fabro_config::WorkflowSettingsBuilder::from_layer(&settings) .expect("workflow settings should resolve") .combined_labels(); @@ -156,7 +156,7 @@ provider = "not-a-provider" "#, ); - let errors = fabro_config::WorkflowSettings::from_layer(&settings) + let errors = fabro_config::WorkflowSettingsBuilder::from_layer(&settings) .expect_err("invalid workflow settings should fail"); assert!(errors.iter().any(|error| { @@ -182,12 +182,9 @@ command = ["echo", "hi"] "#, ); - let rendered = fabro_config::WorkflowSettings::from_layer(&settings) + let rendered = fabro_config::WorkflowSettingsBuilder::from_layer(&settings) .expect_err("invalid workflow settings should fail") - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n"); + .to_string(); assert!(rendered.contains("run.sandbox.provider")); assert!(rendered.contains("run.prepare.steps[0]")); diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index 445fb834f..3f8fe5f4d 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -3,7 +3,7 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::parse_settings_layer; +use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_config::user::default_storage_dir; use fabro_types::settings::server::{ GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings, @@ -92,8 +92,8 @@ 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"); assert_eq!( context.server, @@ -127,7 +127,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); }); @@ -517,9 +518,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 +537,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,11 +553,10 @@ _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), - InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}") - ); + assert_eq!(settings.server.storage.root, InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}")); } #[test] diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 3709bd62d..16e9a84a7 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::{InterpString, ProjectNamespace, WorkflowNamespace}; + use fabro_types::settings::run::{ + DaytonaSettings, DaytonaSnapshotSettings, LocalSandboxSettings, RunGoal, + RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, + }; 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)] @@ -1601,7 +1625,7 @@ session_sandboxes = false .expect("demo settings fixture should parse"); serde_json::to_value( - fabro_config::ServerSettings::from_layer(&settings) + fabro_config::ServerSettingsBuilder::from_layer(&settings) .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..68f090f34 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -1902,7 +1902,8 @@ async fn write_artifact_store_metadata( storage.root = Some(InterpString::parse(&storage_dir.display().to_string())); let resolved = - fabro_config::ServerSettings::from_layer(&settings).map_err(anyhow::Error::from)?; + fabro_config::ServerSettingsBuilder::from_layer(&settings) + .map_err(anyhow::Error::from)?; let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(FABRO_VERSION).await?; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 4186baf1a..6a2399e63 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::WorkflowSettingsBuilder; 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::parse_settings_layer; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; @@ -83,10 +83,13 @@ pub(crate) fn prepare_manifest( .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), - )?; + let mut settings = WorkflowSettingsBuilder::new() + .args_layer(args_layer) + .workflow_layer(workflow_layer) + .project_layer(project_layer) + .user_layer(user_layer) + .server_layer(server_settings.clone()) + .build_layer(); 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))); @@ -359,13 +362,14 @@ async fn build_preflight_report( 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 = + WorkflowSettingsBuilder::from_layer(&materialized).map_err(|errors| anyhow!(errors))?; let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; - let sandbox_provider = resolve_sandbox_provider(&resolved_run)?; - let sandbox_provider = - if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { + let sandbox_provider = resolve_sandbox_provider(&resolved_run.run)?; + let sandbox_provider = if resolved_run.run.execution.mode == RunMode::DryRun + && !sandbox_provider.is_local() + { SandboxProvider::Local } else { sandbox_provider @@ -385,7 +389,7 @@ async fn build_preflight_report( &mut checks, sandbox_provider, prepared, - &resolved_run, + &resolved_run.run, github_app.clone(), daytona_api_key, ) @@ -394,7 +398,7 @@ async fn build_preflight_report( state, &mut checks, graph, - &resolved_run, + &resolved_run.run, &configured_providers, ) .await; @@ -415,8 +419,8 @@ 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()) + let setup_command_count = WorkflowSettingsBuilder::from_layer(&prepared.settings) + .map(|settings| settings.run.prepare.commands.len()) .unwrap_or_default(); let repo_summary = prepared.git.as_ref().map_or_else( || "unknown".to_string(), @@ -978,8 +982,9 @@ root = "/srv/fabro" let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); assert_eq!( - fabro_config::resolve_run_from_file(&prepared.settings) + WorkflowSettingsBuilder::from_layer(&prepared.settings) .unwrap() + .run .execution .mode, fabro_types::settings::run::RunMode::DryRun @@ -1036,25 +1041,15 @@ 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 resolved_run = WorkflowSettingsBuilder::from_layer(&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!(resolved_run.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] diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 101982df8..f4c6fae27 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -7,7 +7,7 @@ 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::{ServerSettingsBuilder, Storage}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_sandbox::SandboxProvider; use fabro_types::settings::server::{ @@ -469,7 +469,7 @@ where } fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result { - ServerSettings::from_layer(file) + ServerSettingsBuilder::from_layer(file) .map(|settings| settings.server) .map_err(anyhow::Error::from) } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index c09ee53e2..02899a2b9 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::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{ServerSettings, Storage}; +use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -73,7 +73,7 @@ use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, - RunSubjectProvenance, + RunSubjectProvenance, ServerSettings, }; use fabro_util::redact::redact_jsonl_line; use fabro_util::text::strip_goal_decoration; @@ -783,7 +783,7 @@ impl AppState { } pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { - let resolved = Arc::new(ServerSettings::from_layer(&settings)?); + let resolved = Arc::new(ServerSettingsBuilder::from_layer(&settings)?); resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; *self.settings.write().expect("settings lock poisoned") = settings; @@ -2585,7 +2585,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result, 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, @@ -9996,12 +9991,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, } @@ -10026,14 +10021,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] diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index 758c69d07..14b90af8d 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -10,7 +10,7 @@ use crate::helpers::{ }; #[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!( r#" @@ -87,20 +87,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/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index a12b067bf..a39565f86 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -214,8 +214,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-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs new file mode 100644 index 000000000..1be00a998 --- /dev/null +++ b/lib/crates/fabro-types/src/dense.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::settings::{ + CliNamespace, FeaturesNamespace, InterpString, 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()); + self + } +} + +#[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 c3d7cdc5f..15a787534 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}; diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 7a3e9ff4c..e014fd872 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::graph::Graph; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -use crate::settings::SettingsLayer; +use crate::WorkflowSettings; #[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/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..c05efff65 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -14,7 +14,7 @@ use super::maps::StickyMap; use super::run::{AgentPermissions, McpEntryLayer, 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,49 +24,49 @@ 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, } diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index 9b4098bc8..f97f2d204 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -10,7 +10,7 @@ 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, diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index fea4d7f85..bbd7c5404 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -18,7 +18,7 @@ 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 +40,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 +80,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 +97,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 +125,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 +145,17 @@ 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 +176,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 +197,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 +207,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 +220,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 +265,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 +395,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 +403,10 @@ pub struct RunScmSettings { pub github: Option, } -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ScmGitHubSettings; +#[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,7 +425,7 @@ impl Default for PullRequestSettings { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ArtifactsSettings { pub include: Vec, } diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index e7d47d045..25b1664ba 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -10,7 +10,7 @@ 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, 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..cc575789a 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -8,6 +8,7 @@ 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}; @@ -202,7 +203,7 @@ impl Handler for SubWorkflowHandler { let child_cancel = Arc::clone(&cancel_token); let child_run_options = RunOptions { - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), run_dir: child_logs, cancel_token: Some(cancel_token), // Child workflows are part of the parent run's event stream. diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index d335cd356..91c826ea5 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -8,15 +8,15 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use fabro_config::{Storage, WorkflowSettings}; +use fabro_config::{Storage, WorkflowSettingsBuilder}; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_sandbox::daytona::detect_repo_info; use fabro_store::Database; use fabro_template::{TemplateContext, render as render_template}; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::RunMode; +use fabro_types::settings::SettingsLayer; use fabro_types::{RunId, RunProvenance}; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -85,8 +85,8 @@ pub async fn create( }) .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) + if WorkflowSettingsBuilder::from_layer(&resolved.settings) + .map_or(true, |settings| settings.run.execution.mode != RunMode::DryRun) { validate_sandbox_provider(&resolved.settings)?; } @@ -108,8 +108,8 @@ pub async fn create( } = 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 resolved_settings = WorkflowSettingsBuilder::from_layer(&settings) + .map_err(|errors| Error::Precondition(errors.to_string()))?; 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(); @@ -272,9 +272,10 @@ fn store_error(err: impl std::fmt::Display) -> Error { } 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)))?; + let resolved = WorkflowSettingsBuilder::from_layer(settings) + .map_err(|errors| Error::Precondition(errors.to_string()))?; resolved + .run .sandbox .provider .parse::() @@ -378,6 +379,8 @@ fn persist_validated( Catalog::builtin(), &configured_providers, ); + let settings = WorkflowSettingsBuilder::from_layer(&settings) + .map_err(|errors| Error::Precondition(errors.to_string()))?; let run_id = run_id.unwrap_or_else(RunId::new); let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id)); @@ -860,8 +863,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 +876,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 +889,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 +899,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() ); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 3a5df431f..7ab032436 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -309,8 +309,7 @@ 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 = diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 82d53bf82..a3ee44810 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 @@ -725,9 +719,8 @@ pub async fn initialize( .run_options .settings .run - .as_ref() - .and_then(|run| run.inputs.clone()) - .unwrap_or_default(), + .inputs + .clone(), run_options: options.run_options, workflow_path: options.workflow_path, workflow_bundle: options.workflow_bundle, diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index 28057beb1..6b780c59f 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -3,8 +3,7 @@ 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::{RunId, WorkflowSettings}; use fabro_types::settings::run::RunMode; 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/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index fe3282e76..ebebefc5f 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -118,9 +118,8 @@ async fn initialized( inputs: run_options .settings .run - .as_ref() - .and_then(|run| run.inputs.clone()) - .unwrap_or_default(), + .inputs + .clone(), run_options: run_options.clone(), workflow_path: None, workflow_bundle: None, 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. From 9220af6e8001053115b60c1bd080b6caf2814fea Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 11:55:12 -0400 Subject: [PATCH 03/60] migrate remaining run settings consumers to dense snapshots --- lib/crates/fabro-api/src/lib.rs | 3 +- lib/crates/fabro-cli/src/command_context.rs | 2 +- lib/crates/fabro-cli/src/commands/dump.rs | 5 +- lib/crates/fabro-cli/src/commands/exec.rs | 4 +- .../fabro-cli/src/commands/run/attach.rs | 7 +- .../fabro-cli/src/commands/run/runner.rs | 6 +- lib/crates/fabro-cli/src/local_server.rs | 12 +- lib/crates/fabro-cli/src/user_config.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 77 +++-- lib/crates/fabro-cli/tests/it/cmd/config.rs | 13 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 9 +- lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 20 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 17 +- .../tests/it/support/auth_harness.rs | 6 +- lib/crates/fabro-config/src/builders.rs | 6 +- lib/crates/fabro-config/src/lib.rs | 2 +- lib/crates/fabro-config/src/resolve/mod.rs | 4 +- lib/crates/fabro-config/tests/defaults.rs | 2 +- .../fabro-config/tests/resolve_server.rs | 7 +- lib/crates/fabro-install/src/lib.rs | 7 +- lib/crates/fabro-server/src/demo/mod.rs | 16 +- lib/crates/fabro-server/src/install.rs | 3 +- lib/crates/fabro-server/src/jwt_auth.rs | 13 +- lib/crates/fabro-server/src/run_manifest.rs | 8 +- lib/crates/fabro-server/src/server.rs | 11 +- lib/crates/fabro-server/src/startup.rs | 8 +- .../tests/it/api/cli_auth_token.rs | 6 +- .../fabro-server/tests/it/api/install.rs | 6 +- .../fabro-server/tests/it/api/routing.rs | 6 +- lib/crates/fabro-server/tests/it/api/tcp.rs | 6 +- lib/crates/fabro-types/src/dense.rs | 4 +- lib/crates/fabro-types/src/run.rs | 2 +- lib/crates/fabro-types/src/settings/run.rs | 8 +- lib/crates/fabro-workflow/src/event.rs | 7 +- .../src/handler/manager_loop.rs | 2 +- .../fabro-workflow/src/operations/create.rs | 8 +- .../fabro-workflow/src/operations/fork.rs | 5 +- .../src/operations/rebuild_meta.rs | 5 +- .../src/pipeline/execute/tests.rs | 7 +- .../fabro-workflow/src/pipeline/finalize.rs | 5 +- .../fabro-workflow/src/pipeline/initialize.rs | 14 +- .../fabro-workflow/src/pipeline/persist.rs | 28 +- .../src/pipeline/pull_request.rs | 7 +- .../fabro-workflow/src/pipeline/retro.rs | 7 +- lib/crates/fabro-workflow/src/run_dump.rs | 5 +- lib/crates/fabro-workflow/src/run_lookup.rs | 5 +- lib/crates/fabro-workflow/src/run_options.rs | 2 +- .../fabro-workflow/src/runtime_store.rs | 5 +- lib/crates/fabro-workflow/src/test_support.rs | 6 +- .../tests/it/daytona_integration.rs | 28 +- .../tests/it/git_integration.rs | 5 +- .../fabro-workflow/tests/it/integration.rs | 272 +++++++++--------- .../fabro-workflow/tests/materialize_run.rs | 9 +- 53 files changed, 373 insertions(+), 367 deletions(-) diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 1f0858261..7655ace4d 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -14,8 +14,6 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } pub mod types { - pub use fabro_types::WorkflowSettings; - pub use fabro_types::ServerSettings; pub use fabro_types::settings::server::{ DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, @@ -29,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-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index f4895d763..e844d2cbb 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use anyhow::{Context as _, Result, bail}; use fabro_config::UserSettingsBuilder; +use fabro_types::UserSettings; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_types::settings::{Combine, SettingsLayer}; -use fabro_types::UserSettings; use fabro_util::printer::Printer; use tokio::sync::OnceCell; diff --git a/lib/crates/fabro-cli/src/commands/dump.rs b/lib/crates/fabro-cli/src/commands/dump.rs index 0748ae671..342dbd3cd 100644 --- a/lib/crates/fabro-cli/src/commands/dump.rs +++ b/lib/crates/fabro-cli/src/commands/dump.rs @@ -307,11 +307,10 @@ mod tests { use chrono::{DateTime, Utc}; use fabro_store::{Database, EventEnvelope, EventPayload}; - use fabro_types::settings::SettingsLayer; use fabro_types::{ AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, SandboxRecord, StageStatus, - StartRecord, SuccessReason, fixtures, + StartRecord, SuccessReason, WorkflowSettings, fixtures, }; use fabro_workflow::event::{Event, append_event}; use object_store::ObjectStore; @@ -349,7 +348,7 @@ mod tests { ); RunSpec { run_id, - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from("/tmp/night-sky"), diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index c8b218daf..8c0fcc8b4 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result as AnyResult; use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; +use fabro_config::WorkflowSettingsBuilder; use fabro_llm::client::Client; use fabro_llm::error::{ Error as LlmError, ProviderErrorDetail, ProviderErrorKind, error_from_status_code, @@ -414,9 +415,10 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu .map(|(name, entry)| runtime_mcp_server(name, entry)) .collect() } else { - fabro_config::resolve_run_from_file(&raw_settings) + WorkflowSettingsBuilder::from_layer(&raw_settings) .map(|settings| { settings + .run .agent .mcps .values() diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 350b7fcd5..c0d302b77 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -84,9 +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| { - record.settings.run.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/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 45a747a5e..f61a85a5a 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -16,8 +16,8 @@ use async_trait::async_trait; use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer}; -use fabro_types::settings::run::RunMode; use fabro_types::settings::InterpString; +use fabro_types::settings::run::RunMode; use fabro_types::{ ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId, WorkflowSettings, }; @@ -513,8 +513,8 @@ fn maybe_build_github_credentials( 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()); + .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 diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 88f2288b6..997e41e18 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -7,10 +7,10 @@ use std::path::PathBuf; use anyhow::Result; -use fabro_config::bind::BindRequest; use fabro_config::ServerSettingsBuilder; -use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +use fabro_config::bind::BindRequest; use fabro_types::ServerSettings; +use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) @@ -50,9 +50,11 @@ pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec { } pub(crate) fn config_log_level(settings: &SettingsLayer) -> Option { - resolved_server_settings(settings) - .ok() - .and_then(|settings| settings.server.logging.level) + settings + .server + .as_ref() + .and_then(|server| server.logging.as_ref()) + .and_then(|logging| logging.level.clone()) } fn resolved_server_settings(settings: &SettingsLayer) -> Result { diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 36394143c..9907aa709 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; -pub(crate) use fabro_config::user::*; use fabro_config::UserSettingsBuilder; +pub(crate) use fabro_config::user::*; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::version::FABRO_VERSION; diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 68af2b229..31ff0536b 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 a12a1b76d..d06395688 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -74,8 +74,9 @@ shared = "server" } fn resolved_server_settings_fixture() -> serde_json::Value { - let settings = fabro_config::ServerSettingsBuilder::from_layer(&server_settings_layer_fixture()) - .expect("server settings fixture should resolve"); + let settings = + fabro_config::ServerSettingsBuilder::from_layer(&server_settings_layer_fixture()) + .expect("server settings fixture should resolve"); serde_json::to_value(settings).expect("resolved settings payload should serialize") } @@ -357,10 +358,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 +368,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 337c5b213..de9d52b8e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -257,7 +257,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"); @@ -299,21 +299,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 6f69c096f..110b7ab79 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs @@ -17,7 +17,7 @@ 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::{ServerSettingsBuilder, parse_settings_layer}; use fabro_server::auth::GithubEndpoints; use fabro_server::ip_allowlist::IpAllowlistConfig; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; @@ -71,7 +71,9 @@ 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 = ServerSettingsBuilder::from_layer(&settings) + .expect("settings should resolve") + .server; 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()), diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index b7479b1eb..9643055f1 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -187,7 +187,7 @@ impl WorkflowSettingsBuilder { pub fn build_layer(self) -> SettingsLayer { let server_defaults = SettingsLayer { version: self.server.version, - run: self.server.run, + run: self.server.run, ..SettingsLayer::default() }; let mut layer = self @@ -207,7 +207,9 @@ impl WorkflowSettingsBuilder { Self::from_layer(&self.build_layer()) } - pub fn from_layer(layer: &SettingsLayer) -> std::result::Result { + 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); diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 22f4252a6..c00f31dd8 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -24,10 +24,10 @@ pub mod user; use std::path::Path; -pub(crate) use defaults::apply_builtin_defaults; pub use builders::{ ResolveErrors, ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder, }; +pub(crate) use defaults::apply_builtin_defaults; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 91825e748..088a8f530 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -9,8 +9,8 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, - ServerNamespace, SettingsLayer, WorkflowNamespace, + CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, + SettingsLayer, WorkflowNamespace, }; pub use features::resolve_features; pub use project::resolve_project; diff --git a/lib/crates/fabro-config/tests/defaults.rs b/lib/crates/fabro-config/tests/defaults.rs index 853bda4c0..e72b97ce1 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/tests/defaults.rs @@ -2,10 +2,10 @@ use fabro_config::{ parse_settings_layer, resolve_run_from_file, resolve_server_from_file, resolve_workflow_from_file, }; -use fabro_types::settings::{Combine, SettingsLayer}; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; use fabro_types::settings::server::ObjectStoreProvider; +use fabro_types::settings::{Combine, SettingsLayer}; fn parse(source: &str) -> SettingsLayer { parse_settings_layer(source).expect("fixture should parse") diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index 3f8fe5f4d..c8d172aa5 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -3,8 +3,8 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_config::user::default_storage_dir; +use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::settings::server::{ GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings, }; @@ -556,7 +556,10 @@ root = "{{ env.FABRO_STORAGE_ROOT }}" let settings = ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve"); - assert_eq!(settings.server.storage.root, InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}")); + assert_eq!( + settings.server.storage.root, + InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}") + ); } #[test] diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index 29d03911a..e75d73768 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::{ @@ -661,8 +661,9 @@ name = "custom" &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"); + let resolved = ServerSettingsBuilder::from_layer(&settings) + .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-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 16e9a84a7..fbf83b4ee 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -755,11 +755,11 @@ mod runs { use fabro_api::types::*; use fabro_types::WorkflowSettings; - use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace}; use fabro_types::settings::run::{ - DaytonaSettings, DaytonaSnapshotSettings, LocalSandboxSettings, RunGoal, - RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, + DaytonaSettings, DaytonaSnapshotSettings, LocalSandboxSettings, RunGoal, RunModelSettings, + RunNamespace, RunPrepareSettings, RunSandboxSettings, }; + use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace}; use super::ts; use crate::server::truncate_goal; @@ -1354,20 +1354,20 @@ mod runs { ..WorkflowNamespace::default() }, run: RunNamespace { - goal: Some(RunGoal::Inline(InterpString::parse( + goal: Some(RunGoal::Inline(InterpString::parse( "Add rate limiting to auth endpoints", ))), working_dir: Some(InterpString::parse("/workspace/api-server")), - model: RunModelSettings { + model: RunModelSettings { provider: Some(InterpString::parse("anthropic")), - name: Some(InterpString::parse("claude-opus-4-6")), + name: Some(InterpString::parse("claude-opus-4-6")), ..RunModelSettings::default() }, - prepare: RunPrepareSettings { + prepare: RunPrepareSettings { commands: vec!["bun install".into(), "bun run typecheck".into()], timeout_ms: 120_000, }, - sandbox: RunSandboxSettings { + sandbox: RunSandboxSettings { provider: "daytona".into(), preserve: false, devcontainer: false, diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 68f090f34..0bbc3186c 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -1902,8 +1902,7 @@ async fn write_artifact_store_metadata( storage.root = Some(InterpString::parse(&storage_dir.display().to_string())); let resolved = - fabro_config::ServerSettingsBuilder::from_layer(&settings) - .map_err(anyhow::Error::from)?; + fabro_config::ServerSettingsBuilder::from_layer(&settings).map_err(anyhow::Error::from)?; let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(FABRO_VERSION).await?; diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index b332cb797..1b210c63e 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, parse_settings_layer}; 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 { @@ -557,7 +558,11 @@ methods = [] ", ) .expect("fixture should parse"); - let errors = resolve_server_from_file(&file).expect_err("empty auth methods should fail"); + let ConfigError::Resolve { errors, .. } = + ServerSettingsBuilder::from_layer(&file).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 6a2399e63..dc63b7c66 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,10 +4,9 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::WorkflowSettingsBuilder; use fabro_config::project::resolve_working_directory; use fabro_config::run::parse_run_config; -use fabro_config::parse_settings_layer; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; @@ -367,9 +366,8 @@ async fn build_preflight_report( let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; let sandbox_provider = resolve_sandbox_provider(&resolved_run.run)?; - let sandbox_provider = if resolved_run.run.execution.mode == RunMode::DryRun - && !sandbox_provider.is_local() - { + let sandbox_provider = + if resolved_run.run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { SandboxProvider::Local } else { sandbox_provider diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 02899a2b9..1c7da9d1d 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::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{ServerSettingsBuilder, Storage}; +use fabro_config::{ServerSettingsBuilder, Storage, WorkflowSettingsBuilder}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -1362,8 +1362,9 @@ async fn get_system_info( 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); + ServerSettingsBuilder::from_layer(settings).is_ok_and(|s| s.features.session_sandboxes); + let retros = + WorkflowSettingsBuilder::from_layer(settings).is_ok_and(|s| s.run.execution.retros); SystemFeatures { session_sandboxes: Some(session_sandboxes), retros: Some(retros), @@ -1656,9 +1657,9 @@ fn build_prune_plan( } fn system_sandbox_provider(settings: &SettingsLayer) -> String { - fabro_config::resolve_run_from_file(settings).map_or_else( + WorkflowSettingsBuilder::from_layer(settings).map_or_else( |_| SandboxProvider::default().to_string(), - |settings| settings.sandbox.provider, + |settings| settings.run.sandbox.provider, ) } 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/tests/it/api/cli_auth_token.rs b/lib/crates/fabro-server/tests/it/api/cli_auth_token.rs index 1581fc988..57ca32e2b 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 @@ -4,7 +4,7 @@ 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_config::{ServerSettingsBuilder, parse_settings_layer}; 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}; @@ -29,7 +29,9 @@ 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 resolved = ServerSettingsBuilder::from_layer(&settings) + .expect("settings should resolve") + .server; 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()), diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 95bc8d4d6..4e25dc447 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, parse_settings_layer}; use fabro_install::OBJECT_STORE_MANAGED_COMMENT; use fabro_model::Provider; use fabro_server::install::{InstallAppState, build_install_router}; @@ -745,7 +745,9 @@ async fn token_install_finish_persists_settings_env_and_vault() { 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_layer(&parsed) + .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..8475711cf 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -4,7 +4,7 @@ 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, parse_settings_layer}; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::server::{ @@ -30,7 +30,9 @@ methods = ["dev-token"] "#, ) .expect("settings fixture should parse"); - let resolved = resolve_server_from_file(&settings).expect("settings should resolve"); + let resolved = ServerSettingsBuilder::from_layer(&settings) + .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()), diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 24d35e242..0fef8bc06 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, parse_settings_layer}; 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}; @@ -168,7 +168,9 @@ methods = ["dev-token"] "#, ) .expect("test settings should parse"); - let resolved = resolve_server_from_file(&settings).expect("test settings should resolve"); + let resolved = ServerSettingsBuilder::from_layer(&settings) + .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-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs index 1be00a998..2cc6fda8b 100644 --- a/lib/crates/fabro-types/src/dense.rs +++ b/lib/crates/fabro-types/src/dense.rs @@ -4,8 +4,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, - ServerNamespace, WorkflowNamespace, + CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, + WorkflowNamespace, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index e014fd872..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::WorkflowSettings; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index bbd7c5404..26d1c1aa3 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -148,12 +148,8 @@ pub enum DockerfileSource { #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum DockerfileSourceRepr { - Inline { - value: String, - }, - Path { - path: String, - }, + Inline { value: String }, + Path { path: String }, } impl Serialize for DockerfileSource { diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 969cbd7ac..48ad45738 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -3492,8 +3492,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, @@ -3506,7 +3507,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/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index cc575789a..589962f43 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -7,8 +7,8 @@ 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 fabro_types::settings::SettingsLayer; use object_store::memory::InMemory; use tokio::fs; use tokio::time::{sleep, timeout}; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 91c826ea5..7fed063fc 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -15,8 +15,8 @@ use fabro_sandbox::SandboxProvider; use fabro_sandbox::daytona::detect_repo_info; use fabro_store::Database; use fabro_template::{TemplateContext, render as render_template}; -use fabro_types::settings::run::RunMode; use fabro_types::settings::SettingsLayer; +use fabro_types::settings::run::RunMode; use fabro_types::{RunId, RunProvenance}; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -85,9 +85,9 @@ pub async fn create( }) .map_err(|err| Error::Parse(err.to_string()))?; - if WorkflowSettingsBuilder::from_layer(&resolved.settings) - .map_or(true, |settings| settings.run.execution.mode != RunMode::DryRun) - { + if WorkflowSettingsBuilder::from_layer(&resolved.settings).map_or(true, |settings| { + settings.run.execution.mode != RunMode::DryRun + }) { validate_sandbox_provider(&resolved.settings)?; } 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/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index a3c61ac30..6ec2b8f5c 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::*; @@ -95,7 +94,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(), @@ -137,7 +136,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 67dd9011c..203741121 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -304,8 +304,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::*; @@ -319,7 +318,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 a3ee44810..a22fc3a6f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -715,12 +715,7 @@ pub async fn initialize( Ok(Initialized { graph, source, - inputs: options - .run_options - .settings - .run - .inputs - .clone(), + inputs: options.run_options.settings.run.inputs.clone(), run_options: options.run_options, workflow_path: options.workflow_path, workflow_bundle: options.workflow_bundle, @@ -754,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; @@ -837,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(), @@ -859,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 fddfce471..734ca78af 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -596,7 +596,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; @@ -1088,7 +1087,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"), @@ -1153,7 +1152,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"), @@ -1371,7 +1370,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_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index 6b780c59f..5ee57fb05 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use fabro_types::{RunId, WorkflowSettings}; use fabro_types::settings::run::RunMode; +use fabro_types::{RunId, WorkflowSettings}; use crate::git::{GitAuthor, git_author_from_settings}; 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 ebebefc5f..f26e86293 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -115,11 +115,7 @@ async fn initialized( initialized: Initialized { graph: graph.clone(), source: String::new(), - inputs: run_options - .settings - .run - .inputs - .clone(), + 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..0d5cc2c01 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"), @@ -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..c15702e70 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/fabro-workflow/tests/materialize_run.rs @@ -1,3 +1,4 @@ +use fabro_config::WorkflowSettingsBuilder; use fabro_graphviz::graph::Graph; use fabro_graphviz::parser; use fabro_model::{Catalog, Provider}; @@ -34,7 +35,9 @@ fn materialize_run_applies_graph_and_catalog_defaults() { }; let materialized = materialize_run(settings, &graph(source), Catalog::builtin(), &[]); - let resolved = fabro_config::resolve_run_from_file(&materialized).unwrap(); + let resolved = WorkflowSettingsBuilder::from_layer(&materialized) + .unwrap() + .run; assert_eq!( resolved @@ -76,7 +79,9 @@ fn materialize_run_uses_configured_provider_defaults() { Catalog::builtin(), &[Provider::OpenAi], ); - let resolved = fabro_config::resolve_run_from_file(&materialized).unwrap(); + let resolved = WorkflowSettingsBuilder::from_layer(&materialized) + .unwrap() + .run; assert_eq!( resolved From 30986207cb28a1d4d58d9dfa99c82bb25cfa4bff Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 12:01:49 -0400 Subject: [PATCH 04/60] trim config wrapper and storage override helpers --- lib/crates/fabro-cli/src/command_context.rs | 18 ++++++++++++--- lib/crates/fabro-cli/src/commands/install.rs | 24 ++++++++------------ lib/crates/fabro-cli/src/manifest_builder.rs | 6 +++-- lib/crates/fabro-cli/src/user_config.rs | 21 ++++++++++++++++- lib/crates/fabro-config/src/run.rs | 16 ++++--------- lib/crates/fabro-config/src/user.rs | 18 --------------- lib/crates/fabro-server/src/run_manifest.rs | 4 ++-- lib/crates/fabro-server/src/serve.rs | 15 +++++++++--- 8 files changed, 66 insertions(+), 56 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index e844d2cbb..107f7dbcf 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -187,9 +187,9 @@ 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_types::settings::server::{ServerLayer, ServerStorageLayer}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -223,6 +223,18 @@ mod tests { } } + fn with_storage_dir_override( + mut layer: fabro_types::settings::SettingsLayer, + path: &std::path::Path, + ) -> fabro_types::settings::SettingsLayer { + 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(&path.display().to_string())); + layer + } + #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); @@ -248,9 +260,9 @@ root = "/srv/fabro/default" "#, ) .expect("settings fixture should parse"); - let override_disk_settings = apply_storage_dir_override( + let override_disk_settings = with_storage_dir_override( base_disk_settings.clone(), - Some(std::path::Path::new("/srv/fabro/override")), + std::path::Path::new("/srv/fabro/override"), ); let (base_settings, base_user_settings) = diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 05ec460ad..83d5bbcd6 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1464,16 +1464,13 @@ 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 parsed_settings = fabro_config::parse_settings_layer(&existing_config_contents) + .context("failed to parse existing settings.toml")?; + let storage_dir = args + .storage_dir + .clone_path() + .or_else(|| local_server::storage_dir(&parsed_settings).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) @@ -1777,11 +1774,8 @@ 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(), - ); + let install_settings = fabro_config::parse_settings_layer(&settings_toml) + .context("failed to parse generated settings.toml")?; fabro_config::ServerSettingsBuilder::from_layer(&install_settings)?; // Secrets and auth material diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 9ffeb71de..616c4516d 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -9,8 +9,9 @@ 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::parse_settings_layer; 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; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; @@ -325,7 +326,8 @@ fn collect_workflow_config_files( config: &types::ManifestWorkflowConfig, files: &mut HashMap, ) -> Result<()> { - let config_layer = parse_run_config(&config.source)?; + let config_layer = parse_settings_layer(&config.source) + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; let dockerfile = config_layer .run .as_ref() diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 9907aa709..ad15109a3 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -4,7 +4,8 @@ use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; use fabro_config::UserSettingsBuilder; -pub(crate) use fabro_config::user::*; +pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; +use fabro_config::user::{default_socket_path, load_settings_config}; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::version::FABRO_VERSION; @@ -31,6 +32,24 @@ pub(crate) fn load_settings_with_config_and_storage_dir( Ok(apply_storage_dir_override(layer, storage_dir)) } +fn apply_storage_dir_override( + mut layer: SettingsLayer, + storage_dir: Option<&Path>, +) -> SettingsLayer { + use fabro_types::settings::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 +} + /// Pull the resolved CLI target configuration out of `[cli.target]`. /// Returns either an http(s) URL or a unix socket path. fn cli_target_from_settings(settings: &CliNamespace) -> Option { diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 2cc603978..a99decc6f 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, @@ -15,15 +14,8 @@ use std::path::{Path, PathBuf}; use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer}; +use crate::Result; 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)) -} /// Load and parse a run config from a TOML file. /// diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 298cf2f48..951ae75aa 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -63,24 +63,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-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index dc63b7c66..936e0ece5 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; use fabro_config::project::resolve_working_directory; -use fabro_config::run::parse_run_config; use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; @@ -199,7 +198,8 @@ fn root_workflow_config_layer( return Ok(SettingsLayer::default()); }; - let mut layer = parse_run_config(&config.source)?; + let mut layer = parse_settings_layer(&config.source) + .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; Ok(layer) } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index f4c6fae27..463b091f1 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -6,12 +6,12 @@ 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::user::load_settings_config; use fabro_config::{ServerSettingsBuilder, Storage}; 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, + GithubIntegrationStrategy, ServerLayer, ServerListenLayer, ServerStorageLayer, WebhookStrategy, }; use fabro_types::settings::{ Combine, GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, @@ -191,7 +191,16 @@ fn apply_runtime_settings( args: &ServeArgs, data_dir: &Path, ) -> SettingsLayer { - apply_storage_dir_override(apply_serve_overrides(base, args), Some(data_dir)) + apply_storage_dir_override(apply_serve_overrides(base, args), data_dir) +} + +fn apply_storage_dir_override(mut settings: SettingsLayer, data_dir: &Path) -> SettingsLayer { + let server = settings.server.get_or_insert_with(ServerLayer::default); + let storage = server + .storage + .get_or_insert_with(ServerStorageLayer::default); + storage.root = Some(InterpString::parse(&data_dir.display().to_string())); + settings } async fn resolve_github_webhook_ip_allowlist( From e20d8d9435f1e365355b8f132cd5b19a831e8734 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 14:38:54 -0400 Subject: [PATCH 05/60] route project namespace resolution through workflow builders --- lib/crates/fabro-config/src/builders.rs | 31 +++++++++++++- lib/crates/fabro-config/src/project.rs | 41 ++++++++----------- lib/crates/fabro-config/tests/defaults.rs | 33 +++++++-------- .../fabro-config/tests/resolve_project.rs | 10 +++-- lib/crates/fabro-config/tests/resolve_root.rs | 31 ++++++++------ lib/crates/fabro-config/tests/resolve_run.rs | 11 +++-- .../fabro-config/tests/resolve_workflow.rs | 10 +++-- 7 files changed, 105 insertions(+), 62 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 9643055f1..b64b8480e 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -1,7 +1,9 @@ use std::fmt; use std::path::Path; -use fabro_types::settings::{CliLayer, Combine, RunLayer, SettingsLayer}; +use fabro_types::settings::{ + CliLayer, Combine, ProjectNamespace, RunLayer, RunNamespace, SettingsLayer, WorkflowNamespace, +}; use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; use crate::load::load_settings_path; @@ -224,6 +226,33 @@ impl WorkflowSettingsBuilder { errors, ) } + + pub(crate) fn project_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); + finish_dense_result(project, errors) + } + + pub(crate) fn workflow_from_layer( + layer: &SettingsLayer, + ) -> std::result::Result { + let layer = apply_builtin_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) + } + + pub(crate) fn run_from_layer( + layer: &SettingsLayer, + ) -> std::result::Result { + let layer = apply_builtin_defaults(layer.clone()); + let mut errors = Vec::new(); + let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + finish_dense_result(run, errors) + } } fn finish_result(value: T, context: &'static str, errors: Vec) -> Result { diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index d62584669..8ed58a4a8 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -16,11 +16,7 @@ use fabro_types::settings::SettingsLayer; 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, WorkflowSettingsBuilder, run}; const CONFIG_FILENAME: &str = ".fabro/project.toml"; #[derive(Clone, Debug)] @@ -32,19 +28,14 @@ pub struct WorkflowPathResolution { 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 { 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) @@ -94,9 +85,10 @@ 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(), @@ -121,7 +113,7 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result PathBuf { - let Some(work_dir) = resolve_run_from_file(settings) + let Some(work_dir) = WorkflowSettingsBuilder::run_from_layer(settings) .ok() .and_then(|settings| settings.working_dir) .map(|value| value.as_source()) @@ -392,7 +384,7 @@ pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf let project_dir = config_path .parent() .expect("config_path should have a parent directory"); - let root = resolve_project_from_file(config) + let root = WorkflowSettingsBuilder::project_from_layer(config) .expect("project settings should resolve") .directory; normalize_joined_path(project_dir, Path::new(&root)) @@ -408,14 +400,14 @@ mod tests { #[test] fn parse_minimal_config() { - let config = parse_project_config("_version = 1\n").unwrap(); + let config = crate::parse_settings_layer("_version = 1\n").unwrap(); assert_eq!(config.version, Some(1)); assert!(config.project.is_none()); } #[test] fn parse_with_project_directory() { - let config = parse_project_config( + let config = crate::parse_settings_layer( r#" _version = 1 @@ -425,14 +417,16 @@ directory = "custom/" ) .unwrap(); assert_eq!( - resolve_project_from_file(&config).unwrap().directory, + WorkflowSettingsBuilder::project_from_layer(&config) + .unwrap() + .directory, "custom/" ); } #[test] fn parse_with_run_execution_retros() { - let config = parse_project_config( + let config = crate::parse_settings_layer( " _version = 1 @@ -453,7 +447,8 @@ 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 = crate::parse_settings_layer("_version = 1\n[llm]\nprovider = \"openai\"\n") + .unwrap_err(); let text = format!("{err:#}"); assert!( text.contains("run.model") || text.contains("llm"), @@ -463,7 +458,7 @@ retros = true #[test] fn parse_higher_version_errors() { - let err = parse_project_config("_version = 2\n").unwrap_err(); + let err = crate::parse_settings_layer("_version = 2\n").unwrap_err(); let chain = format!("{err:#}"); assert!( chain.contains("Upgrade") || chain.to_lowercase().contains("version"), diff --git a/lib/crates/fabro-config/tests/defaults.rs b/lib/crates/fabro-config/tests/defaults.rs index e72b97ce1..4e2278790 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/tests/defaults.rs @@ -1,7 +1,4 @@ -use fabro_config::{ - parse_settings_layer, resolve_run_from_file, resolve_server_from_file, - resolve_workflow_from_file, -}; +use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; use fabro_types::settings::server::ObjectStoreProvider; @@ -98,15 +95,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] @@ -123,10 +124,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/tests/resolve_project.rs b/lib/crates/fabro-config/tests/resolve_project.rs index 345cf9f53..925485135 100644 --- a/lib/crates/fabro-config/tests/resolve_project.rs +++ b/lib/crates/fabro-config/tests/resolve_project.rs @@ -1,11 +1,13 @@ -use fabro_config::{parse_settings_layer, resolve_project_from_file}; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::SettingsLayer; #[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()); @@ -30,7 +32,9 @@ team = "platform" ) .expect("fixture should parse"); - let project = resolve_project_from_file(&settings).expect("project settings should resolve"); + let project = WorkflowSettingsBuilder::from_layer(&settings) + .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/tests/resolve_root.rs index 4c48315a1..56e33a0bc 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -1,4 +1,4 @@ -use fabro_config::parse_settings_layer; +use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::run::RunMode; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -83,23 +83,30 @@ 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 workflow_settings = + WorkflowSettingsBuilder::from_layer(&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"); + ServerSettingsBuilder::from_layer(&settings).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()) ); } diff --git a/lib/crates/fabro-config/tests/resolve_run.rs b/lib/crates/fabro-config/tests/resolve_run.rs index 281916ea1..70bbfd521 100644 --- a/lib/crates/fabro-config/tests/resolve_run.rs +++ b/lib/crates/fabro-config/tests/resolve_run.rs @@ -1,4 +1,4 @@ -use fabro_config::parse_settings_layer; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode}; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -8,8 +8,9 @@ fn parse(source: &str) -> SettingsLayer { #[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); @@ -38,7 +39,9 @@ name = "sonnet" "#, ); - let settings = fabro_config::resolve_run_from_file(&file).expect("run settings should resolve"); + let settings = WorkflowSettingsBuilder::from_layer(&file) + .expect("run settings should resolve") + .run; match settings.goal { Some(RunGoal::File(path)) => { diff --git a/lib/crates/fabro-config/tests/resolve_workflow.rs b/lib/crates/fabro-config/tests/resolve_workflow.rs index c5746caab..154f0dce2 100644 --- a/lib/crates/fabro-config/tests/resolve_workflow.rs +++ b/lib/crates/fabro-config/tests/resolve_workflow.rs @@ -1,11 +1,13 @@ -use fabro_config::{parse_settings_layer, resolve_workflow_from_file}; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::SettingsLayer; #[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()); @@ -30,7 +32,9 @@ tier = "gold" ) .expect("fixture should parse"); - let workflow = resolve_workflow_from_file(&settings).expect("workflow settings should resolve"); + let workflow = WorkflowSettingsBuilder::from_layer(&settings) + .expect("workflow settings should resolve") + .workflow; assert_eq!(workflow.name.as_deref(), Some("Ship")); assert_eq!(workflow.description.as_deref(), Some("Primary flow")); From 43d32464a209a066a07dbfc2383cc60f43028f86 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 14:40:09 -0400 Subject: [PATCH 06/60] drop project run workflow resolve wrappers --- lib/crates/fabro-config/src/lib.rs | 5 ++- lib/crates/fabro-config/src/resolve/mod.rs | 35 +++---------------- lib/crates/fabro-config/tests/resolve_root.rs | 3 +- 3 files changed, 9 insertions(+), 34 deletions(-) diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index c00f31dd8..3f683b55e 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -37,9 +37,8 @@ pub use load::{ pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ ResolveError, dev_token_auth_enabled, 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_workflow, - resolve_workflow_from_file, + resolve_features_from_file, resolve_project, resolve_run, resolve_server, + resolve_server_from_file, resolve_workflow, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 088a8f530..ab9af90ae 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -9,8 +9,7 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, - SettingsLayer, WorkflowNamespace, + CliNamespace, FeaturesNamespace, InterpString, ServerNamespace, SettingsLayer, }; pub use features::resolve_features; pub use project::resolve_project; @@ -36,15 +35,6 @@ pub fn resolve_server_from_file( 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> { @@ -54,22 +44,6 @@ pub fn resolve_features_from_file( 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) -} - pub(crate) fn require_interp( value: Option<&InterpString>, path: &str, @@ -119,8 +93,7 @@ mod tests { use fabro_types::settings::run::{HookType, McpTransport, TlsMode}; - use super::resolve_run_from_file; - use crate::parse_settings_layer; + use crate::{WorkflowSettingsBuilder, parse_settings_layer}; #[test] fn resolve_preserves_source_templates_for_mcp_and_hook_strings() { @@ -164,7 +137,9 @@ Authorization = "Bearer {{ env.HOOK_TOKEN }}" ) .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/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index 56e33a0bc..6e39856ed 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -48,8 +48,9 @@ provider = "not-a-provider" .map(|error| error.to_string()), ); rendered.extend( - fabro_config::resolve_run_from_file(&settings) + fabro_config::WorkflowSettingsBuilder::from_layer(&settings) .expect_err("invalid run settings should fail") + .into_inner() .into_iter() .map(|error| error.to_string()), ); From 916b97c0ad632a4bdefadcc01205ea4b7a1062bc Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 14:43:07 -0400 Subject: [PATCH 07/60] drop cli and features resolve wrappers --- lib/crates/fabro-config/src/lib.rs | 5 ++--- lib/crates/fabro-config/src/resolve/mod.rs | 20 +---------------- lib/crates/fabro-config/tests/resolve_cli.rs | 22 ++++++++++--------- .../fabro-config/tests/resolve_features.rs | 10 ++++++--- .../fabro-config/tests/resolve_server.rs | 14 +++++------- 5 files changed, 27 insertions(+), 44 deletions(-) diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 3f683b55e..cb1aca8b6 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -36,9 +36,8 @@ pub use load::{ }; pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ - ResolveError, dev_token_auth_enabled, resolve_cli, resolve_cli_from_file, resolve_features, - resolve_features_from_file, resolve_project, resolve_run, resolve_server, - resolve_server_from_file, resolve_workflow, + ResolveError, dev_token_auth_enabled, resolve_cli, resolve_features, resolve_project, + resolve_run, resolve_server, resolve_server_from_file, resolve_workflow, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index ab9af90ae..7f7800613 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -8,9 +8,7 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; -use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ServerNamespace, SettingsLayer, -}; +use fabro_types::settings::{InterpString, ServerNamespace, SettingsLayer}; pub use features::resolve_features; pub use project::resolve_project; pub use run::resolve_run; @@ -19,13 +17,6 @@ pub use workflow::resolve_workflow; use crate::apply_builtin_defaults; -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> { @@ -35,15 +26,6 @@ pub fn resolve_server_from_file( 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(crate) fn require_interp( value: Option<&InterpString>, path: &str, diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs index a9a54fad6..782505583 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -3,7 +3,7 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::{parse_settings_layer, resolve_cli_from_file}; +use fabro_config::{UserSettingsBuilder, parse_settings_layer}; use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::AgentPermissions; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -13,7 +13,9 @@ use temp_env::with_var; 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); @@ -43,14 +45,12 @@ session_sandboxes = true .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] @@ -128,7 +128,9 @@ level = "debug" ) .expect("fixture should parse"); - let cli = resolve_cli_from_file(&settings).expect("cli settings should resolve"); + let cli = UserSettingsBuilder::from_layer(&settings) + .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/tests/resolve_features.rs b/lib/crates/fabro-config/tests/resolve_features.rs index 59c6a1740..6d8131707 100644 --- a/lib/crates/fabro-config/tests/resolve_features.rs +++ b/lib/crates/fabro-config/tests/resolve_features.rs @@ -1,11 +1,13 @@ -use fabro_config::{parse_settings_layer, resolve_features_from_file}; +use fabro_config::{UserSettingsBuilder, parse_settings_layer}; 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"); + let features = UserSettingsBuilder::from_layer(&settings) + .expect("empty settings should resolve") + .features; assert!(!features.session_sandboxes); } @@ -22,7 +24,9 @@ session_sandboxes = true ) .expect("fixture should parse"); - let features = resolve_features_from_file(&settings).expect("features should resolve"); + let features = UserSettingsBuilder::from_layer(&settings) + .expect("features should resolve") + .features; assert!(features.session_sandboxes); } diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index c8d172aa5..84a285006 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -94,16 +94,12 @@ session_sandboxes = true 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] From 2ee9850abbe55652950a8c7accf11fac6e20b6dd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 14:57:10 -0400 Subject: [PATCH 08/60] drop server resolve wrapper --- lib/crates/fabro-config/src/lib.rs | 2 +- lib/crates/fabro-config/src/resolve/mod.rs | 21 +-- lib/crates/fabro-config/tests/resolve_root.rs | 28 ++-- .../fabro-config/tests/resolve_server.rs | 122 ++++++++---------- 4 files changed, 76 insertions(+), 97 deletions(-) diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index cb1aca8b6..28217c2e9 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -37,7 +37,7 @@ pub use load::{ pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ ResolveError, dev_token_auth_enabled, resolve_cli, resolve_features, resolve_project, - resolve_run, resolve_server, resolve_server_from_file, resolve_workflow, + resolve_run, resolve_server, resolve_workflow, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 7f7800613..8a4eba96d 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -8,24 +8,13 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; -use fabro_types::settings::{InterpString, ServerNamespace, SettingsLayer}; +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 workflow::resolve_workflow; -use crate::apply_builtin_defaults; - -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(crate) fn require_interp( value: Option<&InterpString>, path: &str, @@ -61,14 +50,6 @@ 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; diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index 6e39856ed..25090b49d 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -8,15 +8,19 @@ fn parse(source: &str) -> SettingsLayer { #[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] @@ -42,10 +46,14 @@ provider = "not-a-provider" let mut rendered = Vec::new(); rendered.extend( - fabro_config::resolve_server_from_file(&settings) + match ServerSettingsBuilder::from_layer(&settings) .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::WorkflowSettingsBuilder::from_layer(&settings) diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index 84a285006..3a85af688 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -22,10 +22,30 @@ fn empty_settings_with_auth_methods() -> SettingsLayer { SettingsLayer::test_default() } +fn resolve_server(file: &SettingsLayer) -> fabro_types::settings::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_errors(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(), @@ -163,13 +183,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_errors( + 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")); @@ -192,8 +209,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 } => { @@ -227,8 +243,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, @@ -247,8 +262,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, @@ -267,15 +281,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); @@ -293,8 +306,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(), @@ -319,8 +331,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 @@ -351,8 +362,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 @@ -379,13 +389,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_errors( + ServerSettingsBuilder::from_layer(&file) + .expect_err("server_url webhook strategy should require server.api.url"), + ); assert!(rendered.contains("server.api.url")); } @@ -404,13 +411,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_errors(ServerSettingsBuilder::from_layer(&file).expect_err( + "configured webhook strategy should require server.integrations.github.app_id", + )); assert!(rendered.contains("server.integrations.github.app_id")); } @@ -426,13 +429,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_errors( + ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"), + ); assert!(rendered.contains("server.ip_allowlist.entries[0]")); } @@ -448,13 +447,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_errors( + ServerSettingsBuilder::from_layer(&file) + .expect_err("github_meta_hooks should be rejected outside github webhooks"), + ); assert!(rendered.contains("server.ip_allowlist.entries[0]")); } @@ -474,13 +470,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_errors( + ServerSettingsBuilder::from_layer(&file) + .expect_err("unix allowlist without trusted proxies should fail"), + ); assert!(rendered.contains("server.ip_allowlist.trusted_proxy_count")); } @@ -500,13 +493,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_errors( + 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") From dc6a92e69685ff7013d26d601b7507749d315aef Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 14:59:48 -0400 Subject: [PATCH 09/60] drop public settings load wrappers --- lib/crates/fabro-cli/src/commands/graph.rs | 5 +-- .../fabro-cli/src/commands/preflight.rs | 5 +-- .../fabro-cli/src/commands/run/create.rs | 5 +-- lib/crates/fabro-cli/src/commands/validate.rs | 5 +-- lib/crates/fabro-cli/src/manifest_builder.rs | 37 +++++++++++++------ lib/crates/fabro-config/src/lib.rs | 5 +-- lib/crates/fabro-config/src/load.rs | 37 ++----------------- 7 files changed, 38 insertions(+), 61 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index b8416a6c3..cd8f9ee74 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -11,8 +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_config::user::{active_settings_path, load_settings_config}; use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; use tracing::debug; @@ -40,7 +39,7 @@ pub(crate) async fn run( args_layer: SettingsLayer::default(), args: None, run_id: None, - user_layer: load_settings_user()?, + user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 5f98e0179..9cebf4f8c 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,6 +1,5 @@ use anyhow::bail; -use fabro_config::load::load_settings_user; -use fabro_config::user::active_settings_path; +use fabro_config::user::{active_settings_path, load_settings_config}; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -27,7 +26,7 @@ pub(crate) async fn execute( args_layer: preflight_args_layer(&args)?, args: preflight_manifest_args(&args), run_id: None, - user_layer: load_settings_user()?, + user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 9f1e5a105..a2de07329 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,5 +1,4 @@ -use fabro_config::load::load_settings_user; -use fabro_config::user::active_settings_path; +use fabro_config::user::{active_settings_path, load_settings_config}; use fabro_types::RunId; use fabro_util::terminal::Styles; @@ -42,7 +41,7 @@ pub(crate) async fn create_run( args_layer: cli_args_config, args: run_manifest_args(args), run_id, - user_layer: load_settings_user()?, + user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 162a980c7..f174b1b73 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,6 +1,5 @@ use anyhow::bail; -use fabro_config::load::load_settings_user; -use fabro_config::user::active_settings_path; +use fabro_config::user::{active_settings_path, load_settings_config}; use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; @@ -23,7 +22,7 @@ pub(crate) async fn run( args_layer: SettingsLayer::default(), args: None, run_id: None, - user_layer: load_settings_user()?, + user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 616c4516d..d001ed4e6 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -8,7 +8,6 @@ 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::parse_settings_layer; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; use fabro_config::run::resolve_run_goal; @@ -30,7 +29,7 @@ pub(crate) struct ManifestBuildInput { pub args: Option, pub run_id: Option, /// User-level settings layer. Production callers load via - /// `load_settings_user()`; tests pass `SettingsLayer::default()`. + /// `load_settings_config(None)`; 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. @@ -57,14 +56,35 @@ struct WorkflowScanInput { } pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { - let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?; + let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; + if root_resolution.workflow_config.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 workflow_layer = root_resolution + .workflow_config + .clone() + .unwrap_or_default() + .combine( + project_config + .as_ref() + .map(|(_, config)| config.clone()) + .unwrap_or_default(), + ); let merged_settings = input .args_layer .clone() .combine(workflow_layer) .combine(input.user_layer); - - let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; 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); @@ -83,12 +103,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result 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) .map_err(|err| Error::parse_file("Failed to parse settings file", path, err))?; @@ -20,37 +20,6 @@ pub fn load_settings_path(path: &Path) -> Result { 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; From 68b9fc13a595da7c91245a6e224ab1fb4346068c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:09:05 -0400 Subject: [PATCH 10/60] inline builtin defaults layer --- lib/crates/fabro-config/src/builders.rs | 17 +++++++++-------- lib/crates/fabro-config/src/defaults.rs | 14 ++------------ lib/crates/fabro-config/src/lib.rs | 1 - 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index b64b8480e..d90aec9db 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -6,6 +6,7 @@ use fabro_types::settings::{ }; use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; +use crate::defaults::DEFAULTS_LAYER; use crate::load::load_settings_path; use crate::parse::parse_settings_layer; use crate::resolve::{ @@ -13,7 +14,7 @@ use crate::resolve::{ resolve_workflow, }; use crate::user::load_settings_config; -use crate::{Error, Result, apply_builtin_defaults}; +use crate::{Error, Result}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolveErrors(pub Vec); @@ -80,7 +81,7 @@ impl ServerSettingsBuilder { } pub fn from_layer(layer: &SettingsLayer) -> Result { - let layer = apply_builtin_defaults(layer.clone()); + 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); @@ -112,7 +113,7 @@ impl UserSettingsBuilder { } pub fn from_layer(layer: &SettingsLayer) -> Result { - let layer = apply_builtin_defaults(layer.clone()); + 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); @@ -198,7 +199,7 @@ impl WorkflowSettingsBuilder { .combine(self.project) .combine(self.user) .combine(server_defaults); - layer = apply_builtin_defaults(layer); + layer = layer.combine(DEFAULTS_LAYER.clone()); layer.server = None; layer.cli = None; layer.features = None; @@ -212,7 +213,7 @@ impl WorkflowSettingsBuilder { pub fn from_layer( layer: &SettingsLayer, ) -> std::result::Result { - let layer = apply_builtin_defaults(layer.clone()); + 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); @@ -230,7 +231,7 @@ impl WorkflowSettingsBuilder { pub(crate) fn project_from_layer( layer: &SettingsLayer, ) -> std::result::Result { - let layer = apply_builtin_defaults(layer.clone()); + 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) @@ -239,7 +240,7 @@ impl WorkflowSettingsBuilder { pub(crate) fn workflow_from_layer( layer: &SettingsLayer, ) -> std::result::Result { - let layer = apply_builtin_defaults(layer.clone()); + 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) @@ -248,7 +249,7 @@ impl WorkflowSettingsBuilder { pub(crate) fn run_from_layer( layer: &SettingsLayer, ) -> std::result::Result { - let layer = apply_builtin_defaults(layer.clone()); + 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_dense_result(run, errors) diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs index 49a282e6d..457397e0d 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -1,20 +1,10 @@ use std::sync::LazyLock; -use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::settings::SettingsLayer; use crate::parse_settings_layer; -static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { +pub(crate) static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { parse_settings_layer(include_str!("defaults.toml")) .expect("embedded defaults.toml must parse as a valid SettingsLayer") }); - -#[must_use] -pub(crate) fn defaults_layer() -> &'static SettingsLayer { - &DEFAULTS_LAYER -} - -#[must_use] -pub(crate) fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { - layer.combine(defaults_layer().clone()) -} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 1b9fbec5b..593caa489 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -27,7 +27,6 @@ use std::path::Path; pub use builders::{ ResolveErrors, ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder, }; -pub(crate) use defaults::apply_builtin_defaults; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; From 2a60b6c3dc25d5b8fcb1abb63d70f74b7f785c11 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:12:48 -0400 Subject: [PATCH 11/60] add workflow settings builder toml entrypoint --- .../fabro-api/tests/server_settings_round_trip.rs | 7 +++---- .../fabro-api/tests/workflow_settings_round_trip.rs | 7 +++---- lib/crates/fabro-config/src/builders.rs | 7 +++++++ lib/crates/fabro-install/src/lib.rs | 8 +++----- lib/crates/fabro-server/src/jwt_auth.rs | 9 +++------ lib/crates/fabro-server/tests/it/api/install.rs | 5 ++--- lib/crates/fabro-server/tests/it/api/routing.rs | 8 +++----- lib/crates/fabro-server/tests/it/api/tcp.rs | 10 ++++------ 8 files changed, 28 insertions(+), 33 deletions(-) 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 ab8ac291b..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,7 @@ use fabro_api::types::{ FeaturesNamespace as ApiFeaturesNamespace, ObjectStoreSettings as ApiObjectStoreSettings, ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings, }; -use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; +use fabro_config::ServerSettingsBuilder; use fabro_types::ServerSettings; use fabro_types::settings::server::ObjectStoreSettings; use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; @@ -19,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 @@ -54,8 +54,7 @@ slug = "fabro-dev" session_sandboxes = true "#, ) - .expect("settings fixture should parse"); - let settings = ServerSettingsBuilder::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 index 0f2c50c6c..4c4212c36 100644 --- a/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs +++ b/lib/crates/fabro-api/tests/workflow_settings_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::WorkflowSettings as ApiWorkflowSettings; -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::WorkflowSettingsBuilder; use fabro_types::WorkflowSettings; #[test] @@ -11,7 +11,7 @@ fn workflow_settings_family_reuses_domain_types() { #[test] fn workflow_settings_json_matches_openapi_shape() { - let layer = parse_settings_layer( + let settings = WorkflowSettingsBuilder::from_toml( r#" _version = 1 @@ -29,8 +29,7 @@ goal = "Ship it" approval = "auto" "#, ) - .expect("settings fixture should parse"); - let settings = WorkflowSettingsBuilder::from_layer(&layer).expect("settings should resolve"); + .expect("settings should resolve"); let json = serde_json::to_value(&settings).expect("workflow settings should serialize"); assert_eq!(json["project"]["directory"], "workspace"); diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index d90aec9db..d5c783d39 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -140,6 +140,13 @@ impl WorkflowSettingsBuilder { Self::default() } + pub fn from_toml(source: &str) -> Result { + let layer = parse_settings_layer(source) + .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 fn args_layer(mut self, layer: SettingsLayer) -> Self { self.args = layer; diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index e75d73768..3e21fa101 100644 --- a/lib/crates/fabro-install/src/lib.rs +++ b/lib/crates/fabro-install/src/lib.rs @@ -657,13 +657,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 = ServerSettingsBuilder::from_layer(&settings) - .expect("settings should resolve") - .server; + .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-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 1b210c63e..0675e9598 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::{Error as ConfigError, ServerSettingsBuilder, parse_settings_layer}; + use fabro_config::{Error as ConfigError, ServerSettingsBuilder}; use fabro_types::IdpIdentity; use fabro_types::settings::ServerAuthMethod; use tower::ServiceExt; @@ -549,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 @@ -557,10 +557,7 @@ _version = 1 methods = [] ", ) - .expect("fixture should parse"); - let ConfigError::Resolve { errors, .. } = - ServerSettingsBuilder::from_layer(&file).expect_err("empty auth methods should fail") - else { + .expect_err("empty auth methods should fail") else { panic!("expected settings resolution error"); }; assert!(errors.iter().any(|err| matches!( diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 4e25dc447..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::{ServerSettingsBuilder, Storage, parse_settings_layer}; +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,7 @@ 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 = ServerSettingsBuilder::from_layer(&parsed) + let resolved = ServerSettingsBuilder::from_toml(&settings) .expect("settings should resolve") .server; assert_eq!( diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 8475711cf..d6db8479d 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -21,7 +21,7 @@ const DEV_TOKEN: &str = 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,10 +29,8 @@ _version = 1 methods = ["dev-token"] "#, ) - .expect("settings fixture should parse"); - let resolved = ServerSettingsBuilder::from_layer(&settings) - .expect("settings should resolve") - .server; + .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()), diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 0fef8bc06..a182a8a64 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, ServerSettingsBuilder, parse_settings_layer}; +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}; @@ -159,7 +159,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,10 +167,8 @@ _version = 1 methods = ["dev-token"] "#, ) - .expect("test settings should parse"); - let resolved = ServerSettingsBuilder::from_layer(&settings) - .expect("test settings should resolve") - .server; + .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()), From 662825ac65fdaf01852a765d72404da9a59934de Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:15:05 -0400 Subject: [PATCH 12/60] use toml builders in config fixtures --- lib/crates/fabro-config/tests/resolve_cli.rs | 18 ++++++------------ .../fabro-config/tests/resolve_features.rs | 11 ++++------- .../fabro-config/tests/resolve_project.rs | 11 ++++------- .../fabro-config/tests/resolve_workflow.rs | 11 ++++------- lib/crates/fabro-server/src/demo/mod.rs | 13 +++++-------- 5 files changed, 23 insertions(+), 41 deletions(-) diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs index 782505583..e3edfa55f 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -3,7 +3,7 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::{UserSettingsBuilder, parse_settings_layer}; +use fabro_config::UserSettingsBuilder; use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::AgentPermissions; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -27,7 +27,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 @@ -39,10 +39,7 @@ url = "https://config.example.com" session_sandboxes = true "#, ) - .expect("fixture should parse"); - - let user_settings = fabro_config::UserSettingsBuilder::from_layer(&settings) - .expect("user settings should resolve"); + .expect("user settings should resolve"); assert_eq!( user_settings.cli.target, @@ -93,7 +90,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,11 +123,8 @@ check = false level = "debug" "#, ) - .expect("fixture should parse"); - - let cli = UserSettingsBuilder::from_layer(&settings) - .expect("cli settings should resolve") - .cli; + .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/tests/resolve_features.rs b/lib/crates/fabro-config/tests/resolve_features.rs index 6d8131707..00c131356 100644 --- a/lib/crates/fabro-config/tests/resolve_features.rs +++ b/lib/crates/fabro-config/tests/resolve_features.rs @@ -1,4 +1,4 @@ -use fabro_config::{UserSettingsBuilder, parse_settings_layer}; +use fabro_config::UserSettingsBuilder; use fabro_types::settings::SettingsLayer; #[test] @@ -14,7 +14,7 @@ fn resolves_features_defaults_from_empty_settings() { #[test] fn resolves_session_sandboxes_flag() { - let settings: SettingsLayer = parse_settings_layer( + let features = UserSettingsBuilder::from_toml( r" _version = 1 @@ -22,11 +22,8 @@ _version = 1 session_sandboxes = true ", ) - .expect("fixture should parse"); - - let features = UserSettingsBuilder::from_layer(&settings) - .expect("features should resolve") - .features; + .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/tests/resolve_project.rs index 925485135..202bf74df 100644 --- a/lib/crates/fabro-config/tests/resolve_project.rs +++ b/lib/crates/fabro-config/tests/resolve_project.rs @@ -1,4 +1,4 @@ -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::WorkflowSettingsBuilder; use fabro_types::settings::SettingsLayer; #[test] @@ -17,7 +17,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 @@ -30,11 +30,8 @@ directory = ".fabro" team = "platform" "#, ) - .expect("fixture should parse"); - - let project = WorkflowSettingsBuilder::from_layer(&settings) - .expect("project settings should resolve") - .project; + .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_workflow.rs b/lib/crates/fabro-config/tests/resolve_workflow.rs index 154f0dce2..37540632d 100644 --- a/lib/crates/fabro-config/tests/resolve_workflow.rs +++ b/lib/crates/fabro-config/tests/resolve_workflow.rs @@ -1,4 +1,4 @@ -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::WorkflowSettingsBuilder; use fabro_types::settings::SettingsLayer; #[test] @@ -17,7 +17,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 @@ -30,11 +30,8 @@ graph = "graphs/ship.dot" tier = "gold" "#, ) - .expect("fixture should parse"); - - let workflow = WorkflowSettingsBuilder::from_layer(&settings) - .expect("workflow settings should resolve") - .workflow; + .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-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index fbf83b4ee..ff487dd96 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1584,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] @@ -1621,12 +1622,8 @@ slug = "fabro-dev" [features] session_sandboxes = false "#, - ) - .expect("demo settings fixture should parse"); - - serde_json::to_value( - fabro_config::ServerSettingsBuilder::from_layer(&settings) - .expect("demo settings fixture should resolve"), + ) + .expect("demo settings fixture should resolve"), ) .expect("demo settings should serialize") }) From 4c8f7fe164df425756927d9550af6b860d7452a4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:20:42 -0400 Subject: [PATCH 13/60] use workflow builders in root and run fixtures --- lib/crates/fabro-config/src/project.rs | 15 ++-- lib/crates/fabro-config/tests/resolve_root.rs | 68 ++++++++----------- lib/crates/fabro-config/tests/resolve_run.rs | 16 ++--- 3 files changed, 42 insertions(+), 57 deletions(-) diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 8ed58a4a8..5252f9a75 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -407,19 +407,18 @@ mod tests { #[test] fn parse_with_project_directory() { - let config = crate::parse_settings_layer( - r#" + assert_eq!( + WorkflowSettingsBuilder::from_toml( + r#" _version = 1 [project] directory = "custom/" "#, - ) - .unwrap(); - assert_eq!( - WorkflowSettingsBuilder::project_from_layer(&config) - .unwrap() - .directory, + ) + .unwrap() + .project + .directory, "custom/" ); } diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index 25090b49d..81a06c92e 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -1,11 +1,7 @@ -use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder}; 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") -} - #[test] fn resolves_root_settings_require_explicit_server_auth_methods() { let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default()) @@ -25,8 +21,7 @@ fn resolves_root_settings_require_explicit_server_auth_methods() { #[test] fn resolve_accumulates_errors_across_namespaces() { - let settings = parse( - r#" + let source = r#" _version = 1 [server.listen] @@ -41,12 +36,11 @@ allowed_usernames = [] [run.sandbox] provider = "not-a-provider" -"#, - ); +"#; let mut rendered = Vec::new(); rendered.extend( - match ServerSettingsBuilder::from_layer(&settings) + match ServerSettingsBuilder::from_toml(source) .expect_err("invalid server settings should fail") { fabro_config::Error::Resolve { errors, .. } => errors, @@ -56,11 +50,14 @@ provider = "not-a-provider" .map(|error| error.to_string()), ); rendered.extend( - fabro_config::WorkflowSettingsBuilder::from_layer(&settings) + match fabro_config::WorkflowSettingsBuilder::from_toml(source) .expect_err("invalid run settings should fail") - .into_inner() - .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"); @@ -71,8 +68,7 @@ provider = "not-a-provider" #[test] fn namespace_resolvers_cover_root_level_settings_shape() { - let settings = parse( - r#" + let source = r#" _version = 1 [project] @@ -89,13 +85,11 @@ methods = ["dev-token"] [run.model] provider = "openai" name = "gpt-5" -"#, - ); +"#; let workflow_settings = - WorkflowSettingsBuilder::from_layer(&settings).expect("workflow settings should resolve"); - let server = - ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); + WorkflowSettingsBuilder::from_toml(source).expect("workflow settings should resolve"); + let server = ServerSettingsBuilder::from_toml(source).expect("server settings should resolve"); assert_eq!(workflow_settings.project.directory, ".fabro"); assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot"); @@ -133,7 +127,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 @@ -149,11 +143,9 @@ shared = "workflow" run = "yes" shared = "run" "#, - ); - - let labels = fabro_config::WorkflowSettingsBuilder::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")); @@ -163,17 +155,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::WorkflowSettingsBuilder::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!( @@ -185,7 +179,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 @@ -196,11 +190,9 @@ provider = "not-a-provider" script = "echo hi" command = ["echo", "hi"] "#, - ); - - let rendered = fabro_config::WorkflowSettingsBuilder::from_layer(&settings) - .expect_err("invalid workflow settings should fail") - .to_string(); + ) + .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/tests/resolve_run.rs index 70bbfd521..414eb0491 100644 --- a/lib/crates/fabro-config/tests/resolve_run.rs +++ b/lib/crates/fabro-config/tests/resolve_run.rs @@ -1,11 +1,7 @@ -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::WorkflowSettingsBuilder; 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") -} - #[test] fn resolves_run_defaults_from_empty_settings() { let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default()) @@ -23,7 +19,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 @@ -37,11 +33,9 @@ file = "{{ env.GOAL_FILE }}" provider = "anthropic" name = "sonnet" "#, - ); - - let settings = WorkflowSettingsBuilder::from_layer(&file) - .expect("run settings should resolve") - .run; + ) + .expect("run settings should resolve") + .run; match settings.goal { Some(RunGoal::File(path)) => { From ded4b3acac130c6a724d9b9559dc9e63af7d12dc Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:20:45 -0400 Subject: [PATCH 14/60] route cli install metadata through dense server settings --- lib/crates/fabro-cli/src/commands/install.rs | 32 +++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 83d5bbcd6..c68fb033b 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; @@ -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::ServerSettingsBuilder::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(()) @@ -1774,9 +1773,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( toml::to_string_pretty(&doc)? }; - let install_settings = fabro_config::parse_settings_layer(&settings_toml) - .context("failed to parse generated settings.toml")?; - fabro_config::ServerSettingsBuilder::from_layer(&install_settings)?; + let install_server_settings = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)?; // Secrets and auth material { @@ -1787,7 +1784,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(), )?; @@ -1826,7 +1828,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}", @@ -1862,13 +1864,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(|| { @@ -2110,7 +2106,7 @@ name = "custom" ); } - fn parse_install_settings(source: &str) -> SettingsLayer { + fn parse_install_settings(source: &str) -> fabro_types::settings::SettingsLayer { fabro_config::parse_settings_layer(source).expect("install settings fixture should parse") } @@ -2941,7 +2937,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 From f757bed5b2020918fa234bfa5547b239efb7f866 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:29:53 -0400 Subject: [PATCH 15/60] use dense server settings in install metadata paths --- lib/crates/fabro-cli/src/commands/install.rs | 6 +- lib/crates/fabro-server/src/install.rs | 59 +++++++++++++++----- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index c68fb033b..2be384d2f 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1551,11 +1551,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) diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 0bbc3186c..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::ServerSettingsBuilder::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 { From b3b0b02b5d3e9513970d99a7e73aecb8586bf35f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:31:17 -0400 Subject: [PATCH 16/60] move cli install storage parsing behind local_server --- lib/crates/fabro-cli/src/commands/install.rs | 4 +-- lib/crates/fabro-cli/src/local_server.rs | 37 +++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 2be384d2f..79dc54173 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1463,12 +1463,10 @@ 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 = fabro_config::parse_settings_layer(&existing_config_contents) - .context("failed to parse existing settings.toml")?; let storage_dir = args .storage_dir .clone_path() - .or_else(|| local_server::storage_dir(&parsed_settings).ok()) + .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(); diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 997e41e18..34dcebe8a 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -7,11 +7,17 @@ use std::path::PathBuf; use anyhow::Result; -use fabro_config::ServerSettingsBuilder; use fabro_config::bind::BindRequest; +use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::ServerSettings; use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +pub(crate) fn storage_dir_from_toml(source: &str) -> Result { + let settings = parse_settings_layer(source) + .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; + storage_dir(&settings) +} + pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) } @@ -60,3 +66,32 @@ pub(crate) fn config_log_level(settings: &SettingsLayer) -> Option { fn resolved_server_settings(settings: &SettingsLayer) -> Result { ServerSettingsBuilder::from_layer(settings).map_err(Into::into) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::storage_dir_from_toml; + + #[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, fabro_config::user::default_storage_dir()); + } +} From daf8c7fb1052a80a39edc20ed9996dce7f58d226 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:44:29 -0400 Subject: [PATCH 17/60] split cli command context off sparse machine settings --- lib/crates/fabro-cli/src/command_context.rs | 133 ++++++++++++------ .../fabro-cli/src/commands/auth/login.rs | 2 +- .../fabro-cli/src/commands/auth/logout.rs | 2 +- .../fabro-cli/src/commands/auth/status.rs | 2 +- .../fabro-cli/src/commands/pr/create.rs | 4 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 10 +- lib/crates/fabro-cli/src/commands/version.rs | 2 +- lib/crates/fabro-cli/src/server_client.rs | 10 +- lib/crates/fabro-cli/src/user_config.rs | 59 ++++---- 9 files changed, 131 insertions(+), 93 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 107f7dbcf..d8156851c 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,10 +2,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_config::UserSettingsBuilder; -use fabro_types::UserSettings; +use fabro_config::{ServerSettingsBuilder, UserSettingsBuilder}; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::{ServerSettings, UserSettings}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -33,16 +33,23 @@ pub(crate) struct CommandContext { cwd: PathBuf, base_config_path: PathBuf, cli_layer: CliLayer, - machine_settings: SettingsLayer, + storage_dir: PathBuf, + server_settings: std::result::Result, user_settings: UserSettings, server_mode: ServerMode, server: OnceCell>, } +struct ResolvedCommandSettings { + storage_dir: PathBuf, + 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); @@ -52,8 +59,9 @@ impl CommandContext { cwd, base_config_path, cli_layer: cli_layer.clone(), - machine_settings, - user_settings, + storage_dir: resolved_settings.storage_dir, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode: ServerMode::None, server: OnceCell::new(), }) @@ -88,8 +96,14 @@ impl CommandContext { &self.cwd } - pub(crate) fn machine_settings(&self) -> &SettingsLayer { - &self.machine_settings + pub(crate) fn storage_dir(&self) -> &Path { + &self.storage_dir + } + + pub(crate) fn server_settings(&self) -> Result<&ServerSettings> { + self.server_settings + .as_ref() + .map_err(|err| anyhow::anyhow!("{err}")) } pub(crate) fn user_settings(&self) -> &UserSettings { @@ -107,7 +121,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 @@ -123,7 +138,8 @@ impl CommandContext { }; server_client::connect_server_with_settings( &target, - &machine_settings, + &user_settings, + &storage_dir, &base_config_path, ) .await @@ -138,8 +154,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, @@ -147,8 +162,9 @@ 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, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode, server: OnceCell::new(), }) @@ -158,7 +174,7 @@ impl CommandContext { fn load_merged_settings( cli_layer: &CliLayer, server_mode: &ServerMode, -) -> Result<(SettingsLayer, UserSettings)> { +) -> Result { let disk_settings = match server_mode { ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, ServerMode::ByStorageDir { @@ -172,14 +188,24 @@ fn load_merged_settings( fn merge_settings_layer( disk_settings: SettingsLayer, cli_layer: &CliLayer, -) -> Result<(SettingsLayer, UserSettings)> { - let machine_settings = SettingsLayer { +) -> Result { + let storage_dir = crate::local_server::storage_dir(&disk_settings)?; + let server_settings = ServerSettingsBuilder::from_layer(&disk_settings).map_err(|err| { + // Keep storage-dir and CLI-target resolution tolerant even when full + // server resolution would reject a partial local settings file. + err.to_string() + }); + let merged_settings = SettingsLayer { cli: Some(cli_layer.clone()), ..SettingsLayer::default() } .combine(disk_settings); - let user_settings = UserSettingsBuilder::from_layer(&machine_settings)?; - Ok((machine_settings, user_settings)) + let user_settings = UserSettingsBuilder::from_layer(&merged_settings)?; + Ok(ResolvedCommandSettings { + storage_dir, + server_settings, + user_settings, + }) } #[cfg(test)] @@ -207,7 +233,7 @@ 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) = + let resolved_settings = merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) .expect("settings should merge"); CommandContext { @@ -216,8 +242,9 @@ mod tests { 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, + server_settings: resolved_settings.server_settings, + user_settings: resolved_settings.user_settings, server_mode: ServerMode::None, server: OnceCell::new(), } @@ -265,33 +292,49 @@ root = "/srv/fabro/default" std::path::Path::new("/srv/fabro/override"), ); - 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"); + let base_settings = merge_settings_layer(base_disk_settings, &cli_layer) + .expect("base settings should merge"); + let connection_settings = merge_settings_layer(override_disk_settings, &cli_layer) + .expect("connection settings should merge"); - 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()) + base_settings.user_settings, + connection_settings.user_settings ); 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()) + 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!(base_settings.server_settings.is_err()); + assert!(connection_settings.server_settings.is_err()); + } + + #[test] + fn storage_dir_stays_available_when_server_settings_do_not_resolve() { + let resolved = merge_settings_layer( + parse_settings_layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro" +"#, + ) + .expect("settings fixture should parse"), + &CliLayer::default(), + ) + .expect("settings should merge"); + + assert_eq!(resolved.storage_dir, PathBuf::from("/srv/fabro")); + assert!(resolved.server_settings.is_err()); } #[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/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 688e03a73..109fe90a9 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -16,7 +16,6 @@ use crate::command_context::CommandContext; use crate::commands::rebuild::rebuild_run_store; use crate::shared::print_json_pretty; use crate::shared::repo::ensure_matching_repo_origin; -use crate::user_config; #[allow( deprecated, @@ -98,9 +97,8 @@ pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext ); } - let vault = user_config::storage_dir(ctx.machine_settings()) + let vault = Vault::load(Storage::new(ctx.storage_dir()).secrets_path()) .ok() - .and_then(|dir| Vault::load(Storage::new(&dir).secrets_path()).ok()) .map(|vault| Arc::new(AsyncRwLock::new(vault))); let configured = configured_providers_from_process_env(vault.as_ref()).await; let model = args.model.unwrap_or_else(|| { diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index db8e2d4f8..2359b3d42 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -5,7 +5,7 @@ mod merge; mod view; use anyhow::{Context, Result, anyhow}; -use fabro_config::{ServerSettingsBuilder, Storage}; +use fabro_config::Storage; use fabro_github::GitHubCredentials; use fabro_types::PullRequestRecord; use fabro_types::settings::InterpString; @@ -13,7 +13,6 @@ use fabro_types::settings::InterpString; use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; use crate::command_context::CommandContext; use crate::shared::github::build_github_credentials; -use crate::user_config; const GITHUB_CREDENTIALS_REQUIRED: &str = "GitHub credentials required — run `fabro install` or set GITHUB_TOKEN"; @@ -33,11 +32,8 @@ pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Resu reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] fn load_github_credentials_required(base_ctx: &CommandContext) -> Result { - let server_settings = ServerSettingsBuilder::from_layer(base_ctx.machine_settings()) - .map_err(anyhow::Error::from)?; - let vault = user_config::storage_dir(base_ctx.machine_settings()) - .ok() - .and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok()); + let server_settings = base_ctx.server_settings()?; + let vault = fabro_vault::Vault::load(Storage::new(base_ctx.storage_dir()).secrets_path()).ok(); let creds = build_github_credentials( server_settings.server.integrations.github.strategy, server_settings 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/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index fc82ede62..277525268 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)] @@ -59,14 +58,15 @@ pub(crate) async fn connect_server_target_direct(target: &str) -> Result 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; @@ -74,7 +74,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/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index ad15109a3..b9c28e3f0 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,18 +1,17 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; -use fabro_config::UserSettingsBuilder; pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; use fabro_config::user::{default_socket_path, load_settings_config}; +use fabro_types::UserSettings; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::version::FABRO_VERSION; use tracing::debug; use crate::args::ServerTargetArgs; -use crate::local_server; pub(crate) fn load_settings() -> anyhow::Result { load_settings_with_config_and_storage_dir(None, None) @@ -60,9 +59,8 @@ fn cli_target_from_settings(settings: &CliNamespace) -> Option { } } -fn configured_server_target(settings: &SettingsLayer) -> Result> { - let user_settings = UserSettingsBuilder::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) @@ -72,13 +70,6 @@ pub(crate) fn default_server_target() -> ServerTarget { ServerTarget::unix_socket_path(default_socket_path()).expect("default socket path is absolute") } -#[deprecated( - note = "use local_server::storage_dir for lifecycle; PR commands must move to server-side API" -)] -pub(crate) fn storage_dir(settings: &SettingsLayer) -> anyhow::Result { - local_server::storage_dir(settings) -} - fn parse_server_target(value: &str) -> Result { ServerTarget::from_str(value) } @@ -89,14 +80,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)) } @@ -112,16 +103,16 @@ pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder { } #[cfg(test)] -#[allow( - deprecated, - reason = "the storage_dir tests are exercising the deprecated helper by definition" -)] mod tests { - use fabro_config::parse_settings_layer; + use std::path::PathBuf; + use fabro_config::user::default_storage_dir; + use fabro_config::{UserSettingsBuilder, parse_settings_layer}; + use fabro_types::UserSettings; use super::*; use crate::args::ServerTargetArgs; + use crate::local_server; fn server_target_args(value: Option<&str>) -> ServerTargetArgs { ServerTargetArgs { @@ -129,7 +120,11 @@ mod tests { } } - fn parse_v2(source: &str) -> SettingsLayer { + fn parse_user_settings(source: &str) -> UserSettings { + UserSettingsBuilder::from_toml(source).expect("fixture should resolve") + } + + fn parse_layer(source: &str) -> SettingsLayer { parse_settings_layer(source).expect("fixture should parse") } @@ -161,7 +156,7 @@ mod tests { #[test] fn resolve_server_target_uses_configured_server_target() { - let settings = parse_v2( + let settings = parse_user_settings( r#" _version = 1 @@ -178,7 +173,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 @@ -199,7 +194,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")) @@ -209,7 +204,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 @@ -241,12 +236,15 @@ url = "https://config.example.com" fn storage_dir_defaults_without_server_auth_methods() { let settings = SettingsLayer::default(); - assert_eq!(storage_dir(&settings).unwrap(), default_storage_dir()); + assert_eq!( + local_server::storage_dir(&settings).unwrap(), + default_storage_dir() + ); } #[test] fn storage_dir_uses_explicit_server_storage_root() { - let settings = parse_v2( + let settings = parse_layer( r#" _version = 1 @@ -255,12 +253,15 @@ root = "/srv/fabro" "#, ); - assert_eq!(storage_dir(&settings).unwrap(), PathBuf::from("/srv/fabro")); + assert_eq!( + local_server::storage_dir(&settings).unwrap(), + PathBuf::from("/srv/fabro") + ); } #[test] fn storage_dir_resolves_env_interpolated_root() { - let settings = parse_v2( + let settings = parse_layer( r#" _version = 1 From 84b79f9d69d7c17695eeca984c1b354bc1e89721 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:49:58 -0400 Subject: [PATCH 18/60] derive local server cli config from lifecycle settings --- lib/crates/fabro-cli/src/commands/install.rs | 7 +- .../fabro-cli/src/commands/server/mod.rs | 28 +++--- .../fabro-cli/src/commands/server/start.rs | 9 +- .../fabro-cli/src/commands/uninstall.rs | 4 +- lib/crates/fabro-cli/src/local_server.rs | 85 ++++++++++++++----- lib/crates/fabro-cli/src/main.rs | 7 +- lib/crates/fabro-server/src/serve.rs | 40 +++------ 7 files changed, 103 insertions(+), 77 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 79dc54173..27a10f6da 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -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"; @@ -1606,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(); 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..2e660d84b 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -57,9 +57,9 @@ 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()) + .map(|settings| settings.storage_dir().to_path_buf()) .unwrap_or_else(user_config::default_storage_dir); let inventory = build_inventory(&home_root, &storage_dir)?; diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 34dcebe8a..2dcb35629 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -4,7 +4,7 @@ //! `[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; @@ -12,6 +12,68 @@ use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::ServerSettings; use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +use crate::user_config; + +pub(crate) struct LocalServerConfig { + storage_dir: PathBuf, + auth_methods: Vec, + config_log_level: Option, + server_settings: std::result::Result, +} + +impl LocalServerConfig { + pub(crate) fn load(config_path: Option<&Path>, storage_dir: Option<&Path>) -> Result { + let settings = + user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?; + Self::from_layer(&settings) + } + + pub(crate) fn load_with_storage_dir(storage_dir: Option<&Path>) -> Result { + let settings = user_config::load_settings_with_storage_dir(storage_dir)?; + Self::from_layer(&settings) + } + + fn from_layer(settings: &SettingsLayer) -> Result { + let storage_dir = storage_dir(settings)?; + let config_log_level = settings + .server + .as_ref() + .and_then(|server| server.logging.as_ref()) + .and_then(|logging| logging.level.clone()); + let server_settings = resolved_server_settings(settings).map_err(|err| err.to_string()); + let auth_methods = server_settings + .as_ref() + .map(|resolved| resolved.server.auth.methods.clone()) + .unwrap_or_default(); + Ok(Self { + storage_dir, + auth_methods, + 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}"))?; + fabro_server::serve::resolve_bind_request_from_server_settings(settings, cli_override) + } +} + pub(crate) fn storage_dir_from_toml(source: &str) -> Result { let settings = parse_settings_layer(source) .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; @@ -42,27 +104,6 @@ pub(crate) fn storage_dir_with_lookup( Ok(PathBuf::from(resolved_root.value)) } -pub(crate) fn bind_request( - settings: &SettingsLayer, - cli_override: Option<&str>, -) -> Result { - fabro_server::serve::resolve_bind_request_from_settings(settings, cli_override) -} - -pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec { - resolved_server_settings(settings) - .map(|resolved| resolved.server.auth.methods) - .unwrap_or_default() -} - -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()) -} - fn resolved_server_settings(settings: &SettingsLayer) -> Result { ServerSettingsBuilder::from_layer(settings).map_err(Into::into) } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index f9d58d510..e5cfaea1e 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -421,9 +421,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?) @@ -435,7 +434,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-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 463b091f1..27989db55 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -10,11 +10,12 @@ use fabro_config::user::load_settings_config; use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_sandbox::SandboxProvider; +use fabro_types::ServerSettings; use fabro_types::settings::server::{ - GithubIntegrationStrategy, ServerLayer, ServerListenLayer, ServerStorageLayer, WebhookStrategy, + GithubIntegrationStrategy, ServerLayer, ServerStorageLayer, WebhookStrategy, }; use fabro_types::settings::{ - Combine, GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, + GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, ServerNamespace, SettingsLayer, }; use fabro_util::terminal::Styles; @@ -496,34 +497,17 @@ pub fn resolve_bind_request_from_settings( settings: &SettingsLayer, 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) + let resolved = ServerSettingsBuilder::from_layer(settings).map_err(anyhow::Error::from)?; + resolve_bind_request_from_server_settings(&resolved, explicit_bind) } -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() +pub fn resolve_bind_request_from_server_settings( + settings: &ServerSettings, + explicit_bind: Option<&str>, +) -> anyhow::Result { + match explicit_bind.map(bind::parse_bind).transpose()? { + Some(bind) => Ok(bind), + None => resolved_bind_request(&settings.server), } } From dde726936d87c16b82964d413f993be30f8d0161 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 15:57:51 -0400 Subject: [PATCH 19/60] cache dense workflow settings in prepared manifests --- lib/crates/fabro-server/src/run_manifest.rs | 50 +++++++++++++-------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 936e0ece5..67fc98ce2 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::project::resolve_working_directory; use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; @@ -15,7 +14,6 @@ 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::interp::InterpString; use fabro_types::settings::run::{ @@ -24,6 +22,7 @@ use fabro_types::settings::run::{ RunSandboxLayer, }; 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; @@ -41,7 +40,8 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, - pub settings: SettingsLayer, + pub settings: WorkflowSettings, + pub settings_layer: SettingsLayer, pub target_path: PathBuf, pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, @@ -81,7 +81,7 @@ pub(crate) fn prepare_manifest( .try_fold(SettingsLayer::default(), |layer, config| { Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) })?; - let mut settings = WorkflowSettingsBuilder::new() + let mut settings_layer = WorkflowSettingsBuilder::new() .args_layer(args_layer) .workflow_layer(workflow_layer) .project_layer(project_layer) @@ -89,9 +89,11 @@ pub(crate) fn prepare_manifest( .server_layer(server_settings.clone()) .build_layer(); if let Some(goal) = manifest.goal.as_ref() { - let run = settings.run.get_or_insert_with(RunLayer::default); + let run = settings_layer.run.get_or_insert_with(RunLayer::default); run.goal = Some(RunGoalLayer::Inline(InterpString::parse(&goal.text))); } + let settings = WorkflowSettingsBuilder::from_layer(&settings_layer) + .map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?; Ok(PreparedManifest { cwd: cwd.clone(), @@ -104,6 +106,7 @@ pub(crate) fn prepare_manifest( .transpose() .map_err(|err| anyhow!("invalid run ID: {err}"))?, settings: settings.clone(), + settings_layer: settings_layer.clone(), target_path, workflow_bundle, workflow_input, @@ -116,7 +119,7 @@ pub(crate) fn validate_prepared_manifest( ) -> Result { validate(ValidateInput { workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()), - settings: prepared.settings.clone(), + settings: prepared.settings_layer.clone(), cwd: prepared.cwd.clone(), custom_transforms: Vec::new(), }) @@ -128,7 +131,7 @@ pub(crate) fn create_run_input( ) -> CreateRunInput { CreateRunInput { workflow: WorkflowInput::Bundled(prepared.workflow_input), - settings: prepared.settings, + settings: prepared.settings_layer, cwd: prepared.cwd, workflow_slug: None, workflow_path: Some(prepared.target_path), @@ -281,6 +284,23 @@ 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, config_path: &Path, @@ -353,10 +373,9 @@ 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_layer.clone(), graph, Catalog::builtin(), &configured_providers, @@ -417,9 +436,7 @@ async fn build_preflight_report( } fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec { - let setup_command_count = WorkflowSettingsBuilder::from_layer(&prepared.settings) - .map(|settings| settings.run.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| { @@ -980,11 +997,7 @@ root = "/srv/fabro" let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); assert_eq!( - WorkflowSettingsBuilder::from_layer(&prepared.settings) - .unwrap() - .run - .execution - .mode, + prepared.settings.run.execution.mode, fabro_types::settings::run::RunMode::DryRun ); } @@ -1039,12 +1052,11 @@ app_id = "snapshotted-app-id" }); let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); - let resolved_run = WorkflowSettingsBuilder::from_layer(&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.run.prepare.commands, vec![ + assert_eq!(prepared.settings.run.prepare.commands, vec![ "workflow-setup".to_string() ]); assert!(settings_json.pointer("/server").is_none()); From dc1640e738452aaee6c139c9d01ff7ed697fc509 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:00:06 -0400 Subject: [PATCH 20/60] drop exec raw cli mcp fallback --- lib/crates/fabro-cli/src/commands/exec.rs | 110 +--------------------- 1 file changed, 2 insertions(+), 108 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 8c0fcc8b4..ca069da5c 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -13,10 +13,9 @@ 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; @@ -25,90 +24,6 @@ use crate::args::ExecArgs; use crate::command_context::CommandContext; 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, @@ -392,28 +307,7 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu // `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() + cli.exec.agent.mcps.values().cloned().collect() } else { WorkflowSettingsBuilder::from_layer(&raw_settings) .map(|settings| { From 8290d693ad5411850e67ca9614e77ee6c05f49a5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:09:37 -0400 Subject: [PATCH 21/60] cache manifest defaults separately from server settings --- lib/crates/fabro-server/src/run_manifest.rs | 38 ++++++++++---- lib/crates/fabro-server/src/server.rs | 55 +++++++++++++++------ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 67fc98ce2..a12ca2842 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -48,8 +48,16 @@ pub(crate) struct PreparedManifest { pub working_directory: PathBuf, } +pub(crate) fn manifest_defaults_layer(settings: &SettingsLayer) -> SettingsLayer { + SettingsLayer { + version: settings.version, + run: settings.run.clone(), + ..SettingsLayer::default() + } +} + pub(crate) fn prepare_manifest( - server_settings: &SettingsLayer, + manifest_defaults: &SettingsLayer, manifest: &types::RunManifest, ) -> Result { if manifest.version != 1 { @@ -86,7 +94,7 @@ pub(crate) fn prepare_manifest( .workflow_layer(workflow_layer) .project_layer(project_layer) .user_layer(user_layer) - .server_layer(server_settings.clone()) + .server_layer(manifest_defaults.clone()) .build_layer(); if let Some(goal) = manifest.goal.as_ref() { let run = settings_layer.run.get_or_insert_with(RunLayer::default); @@ -970,7 +978,7 @@ mod tests { #[test] fn prepare_manifest_preserves_explicit_manifest_dry_run() { - let server_settings = server_settings_fixture( + let server_settings = manifest_defaults_layer(&server_settings_fixture( r#" _version = 1 @@ -980,7 +988,7 @@ mode = "dry_run" [server.storage] root = "/srv/fabro" "#, - ); + )); let mut manifest = minimal_manifest(); manifest.args = Some(types::ManifestArgs { auto_approve: None, @@ -1004,7 +1012,7 @@ root = "/srv/fabro" #[test] fn prepare_manifest_prefers_bundled_settings_without_duplication() { - let server_settings = server_settings_fixture( + let server_settings = manifest_defaults_layer(&server_settings_fixture( r#" _version = 1 @@ -1017,7 +1025,7 @@ script = "cli-setup" [server.integrations.github] app_id = "snapshotted-app-id" "#, - ); + )); let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().config = @@ -1065,7 +1073,11 @@ app_id = "snapshotted-app-id" #[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_defaults_layer(&default_settings_fixture()), + &invalid_manifest(), + ) + .unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(validated.has_errors()); @@ -1100,7 +1112,11 @@ enabled = true type_: types::ManifestConfigType::Project, }); - let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); + let prepared = prepare_manifest( + &manifest_defaults_layer(&default_settings_fixture()), + &manifest, + ) + .unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(!validated.has_errors()); @@ -1137,7 +1153,11 @@ provider = "daytona" type_: types::ManifestConfigType::Project, }); - let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); + let prepared = prepare_manifest( + &manifest_defaults_layer(&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/server.rs b/lib/crates/fabro-server/src/server.rs index 1c7da9d1d..04657e845 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -576,6 +576,7 @@ pub struct AppState { pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, pub(crate) settings: Arc>, + manifest_defaults: RwLock>, pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, http_client: Option, @@ -641,6 +642,15 @@ fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelU } impl AppState { + pub(crate) fn manifest_defaults(&self) -> Arc { + Arc::clone( + &self + .manifest_defaults + .read() + .expect("manifest defaults lock poisoned"), + ) + } + pub(crate) fn server_settings(&self) -> Arc { Arc::clone( &self @@ -784,9 +794,14 @@ impl AppState { pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { let resolved = Arc::new(ServerSettingsBuilder::from_layer(&settings)?); + let manifest_defaults = Arc::new(run_manifest::manifest_defaults_layer(&settings)); resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; *self.settings.write().expect("settings lock poisoned") = settings; + *self + .manifest_defaults + .write() + .expect("manifest defaults lock poisoned") = manifest_defaults; *self .server_settings .write() @@ -2584,9 +2599,12 @@ 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_defaults = state.manifest_defaults(); + let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &req) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -4139,10 +4156,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_defaults = state.manifest_defaults(); + let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &req) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -4168,10 +4183,8 @@ 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, - ) { + let manifest_defaults = state.manifest_defaults(); + let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &req.manifest) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -7486,6 +7499,9 @@ methods = ["dev-token"] [server.web] url = "http://new.example.com" +[run.execution] +mode = "dry_run" + [server.storage] root = "/srv/new" "#, @@ -7501,6 +7517,17 @@ root = "/srv/new" state.server_settings().server.storage.root.as_source(), "/srv/new" ); + let manifest_defaults = state.manifest_defaults(); + assert_eq!(manifest_defaults.version, Some(1)); + assert_eq!( + manifest_defaults + .run + .as_ref() + .and_then(|run| run.execution.as_ref()) + .and_then(|execution| execution.mode), + Some(RunMode::DryRun) + ); + assert!(manifest_defaults.server.is_none()); let layer_root = state .settings From a05dc101f2a0358c80733881643e4de74f58842d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:11:46 -0400 Subject: [PATCH 22/60] route system info through dense server settings --- lib/crates/fabro-server/src/server.rs | 74 ++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 04657e845..4c8408d09 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1332,11 +1332,8 @@ async fn get_system_info( _auth: AuthenticatedService, State(state): State>, ) -> Response { - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let manifest_defaults = state.manifest_defaults(); + let server_settings = state.server_settings(); let (total_runs, active_runs) = { let runs = state.runs.lock().expect("runs lock poisoned"); let active = runs @@ -1369,17 +1366,22 @@ 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_defaults.as_ref())), + features: Some(system_features( + server_settings.as_ref(), + manifest_defaults.as_ref(), + )), }; (StatusCode::OK, Json(response)).into_response() } -fn system_features(settings: &SettingsLayer) -> SystemFeatures { - let session_sandboxes = - ServerSettingsBuilder::from_layer(settings).is_ok_and(|s| s.features.session_sandboxes); - let retros = - WorkflowSettingsBuilder::from_layer(settings).is_ok_and(|s| s.run.execution.retros); +fn system_features( + server_settings: &ServerSettings, + manifest_defaults: &SettingsLayer, +) -> SystemFeatures { + let session_sandboxes = server_settings.features.session_sandboxes; + let retros = WorkflowSettingsBuilder::from_layer(manifest_defaults) + .is_ok_and(|s| s.run.execution.retros); SystemFeatures { session_sandboxes: Some(session_sandboxes), retros: Some(retros), @@ -1671,8 +1673,8 @@ fn build_prune_plan( }) } -fn system_sandbox_provider(settings: &SettingsLayer) -> String { - WorkflowSettingsBuilder::from_layer(settings).map_or_else( +fn system_sandbox_provider(manifest_defaults: &SettingsLayer) -> String { + WorkflowSettingsBuilder::from_layer(manifest_defaults).map_or_else( |_| SandboxProvider::default().to_string(), |settings| settings.run.sandbox.provider, ) @@ -7541,6 +7543,50 @@ root = "/srv/new" assert_eq!(layer_root.as_deref(), Some("/srv/new")); } + #[test] + fn system_features_use_dense_server_and_manifest_defaults() { + let settings = fabro_config::parse_settings_layer( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[features] +session_sandboxes = true + +[run.execution] +retros = false +"#, + ) + .expect("settings fixture should parse"); + let server_settings = + ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); + let manifest_defaults = run_manifest::manifest_defaults_layer(&settings); + let features = system_features(&server_settings, &manifest_defaults); + + assert_eq!(features.session_sandboxes, Some(true)); + assert_eq!(features.retros, Some(false)); + } + + #[test] + fn system_sandbox_provider_uses_manifest_defaults() { + let settings = fabro_config::parse_settings_layer( + r#" +_version = 1 + +[run.sandbox] +provider = "daytona" +"#, + ) + .expect("settings fixture should parse"); + + assert_eq!( + system_sandbox_provider(&run_manifest::manifest_defaults_layer(&settings)), + "daytona" + ); + } + #[tokio::test] async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() { let state = create_app_state(); From 45a4802c65e612120d8eb17cc4e6effa9a789eec Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:13:06 -0400 Subject: [PATCH 23/60] drop raw settings cache from app state --- lib/crates/fabro-server/src/serve.rs | 13 +++++++++---- lib/crates/fabro-server/src/server.rs | 14 -------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 27989db55..ca1006e2d 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -730,6 +730,7 @@ 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 data_dir_for_poll = data_dir.clone(); @@ -746,15 +747,19 @@ where &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 }; if changed { - match state_for_poll.replace_settings(effective) { - Ok(()) => info!("Server config reloaded"), + match state_for_poll.replace_settings(effective.clone()) { + Ok(()) => { + *shared_settings_for_poll + .write() + .expect("config lock poisoned") = effective; + info!("Server config reloaded"); + } Err(err) => { warn!(error = %err, "Rejected reloaded server config, keeping previous"); } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 4c8408d09..ee47d1cdf 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -575,7 +575,6 @@ pub struct AppState { pub(crate) vault: Arc>, pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, - pub(crate) settings: Arc>, manifest_defaults: RwLock>, pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, @@ -797,7 +796,6 @@ impl AppState { let manifest_defaults = Arc::new(run_manifest::manifest_defaults_layer(&settings)); resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; - *self.settings.write().expect("settings lock poisoned") = settings; *self .manifest_defaults .write() @@ -2646,7 +2644,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Date: Thu, 23 Apr 2026 16:20:11 -0400 Subject: [PATCH 24/60] add dense run goal and working dir helpers --- lib/crates/fabro-config/src/project.rs | 31 ++++++-- lib/crates/fabro-config/src/run.rs | 101 ++++++++++++++++++++----- 2 files changed, 105 insertions(+), 27 deletions(-) diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 5252f9a75..4736db113 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,7 +12,7 @@ use std::fmt::Write; use std::path::{Component, Path, PathBuf}; -use fabro_types::settings::SettingsLayer; +use fabro_types::settings::{RunNamespace, SettingsLayer}; use serde::Serialize; use crate::load::load_settings_path; @@ -113,14 +113,17 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result PathBuf { - let Some(work_dir) = WorkflowSettingsBuilder::run_from_layer(settings) - .ok() - .and_then(|settings| settings.working_dir) - .map(|value| value.as_source()) - else { + let Some(run_settings) = WorkflowSettingsBuilder::run_from_layer(settings).ok() else { return caller_cwd.to_path_buf(); }; - let path = PathBuf::from(&work_dir); + resolve_working_directory_from_run(&run_settings, caller_cwd) +} + +pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf { + let Some(work_dir) = run.working_dir.as_ref().map(|value| value.as_source()) else { + return caller_cwd.to_path_buf(); + }; + let path = PathBuf::from(work_dir); if path.is_absolute() { path } else { @@ -585,4 +588,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( + &fabro_types::settings::RunNamespace { + working_dir: Some(fabro_types::settings::InterpString::parse("repo")), + ..fabro_types::settings::RunNamespace::default() + }, + cwd, + ); + + assert_eq!(resolved, cwd.join("repo")); + } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index a99decc6f..e27b18c79 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -11,8 +11,10 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer}; +use fabro_types::settings::run::{ + ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunGoalLayer, RunNamespace, +}; +use fabro_types::settings::{InterpString, SettingsLayer}; use crate::Result; use crate::load::{load_settings_path, resolve_goal_file_path}; @@ -76,32 +78,67 @@ pub fn resolve_run_goal( 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, RunGoalLayer}; use super::*; @@ -153,4 +190,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, + }); + } } From 4ce91cdc641a44d8565a5cd05c5452e3455914e4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:20:14 -0400 Subject: [PATCH 25/60] use dense run settings in manifest and workflow loaders --- lib/crates/fabro-cli/src/manifest_builder.rs | 43 ++++++------ .../fabro-workflow/src/operations/source.rs | 67 +++++++++++++++---- 2 files changed, 73 insertions(+), 37 deletions(-) diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index d001ed4e6..3879d98bd 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -8,15 +8,15 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use fabro_api::types; -use fabro_config::parse_settings_layer; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; -use fabro_config::run::resolve_run_goal; +use fabro_config::run::{resolve_run_goal, resolve_run_goal_from_namespace}; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::RunId; +use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal}; -use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::{RunId, WorkflowSettings}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; @@ -70,21 +70,18 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result Result> { - let working_directory = project::resolve_working_directory(settings, cwd); + let working_directory = project::resolve_working_directory_from_run(&settings.run, cwd); // Precedence 1: CLI args (`--goal` / `--goal-file`). These are already // resolved to absolute paths by `overrides::goal_layer_from_args`. @@ -423,7 +420,7 @@ fn resolve_manifest_goal( // 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))); diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index 390109d1a..9aea42605 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -6,10 +6,11 @@ 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 anyhow::{Context, anyhow}; +use fabro_config::run::resolve_run_goal_from_namespace; +use fabro_config::{WorkflowSettingsBuilder, project as project_config}; use fabro_types::settings::SettingsLayer; +use fabro_types::settings::run::RunNamespace; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -67,11 +68,10 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< WorkflowInput::Path(workflow_path) => { let resolution = project_config::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 goal_override = resolve_goal_override(&settings, &working_directory)?; + let (_run_settings, working_directory, goal_override) = + resolve_runtime_run_settings(&settings, &request.cwd)?; let current_dir = resolution .dot_path .parent() @@ -94,9 +94,8 @@ 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 goal_override = resolve_goal_override(&settings, &working_directory)?; + let (_run_settings, working_directory, goal_override) = + resolve_runtime_run_settings(&settings, &request.cwd)?; let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { raw_source: source, @@ -116,9 +115,8 @@ 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 goal_override = resolve_goal_override(&settings, &working_directory)?; + let (_run_settings, working_directory, goal_override) = + resolve_runtime_run_settings(&settings, &request.cwd)?; Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), @@ -135,15 +133,27 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } } +fn resolve_runtime_run_settings( + settings: &SettingsLayer, + cwd: &Path, +) -> anyhow::Result<(RunNamespace, PathBuf, Option)> { + let run_settings = WorkflowSettingsBuilder::from_layer(settings) + .map_err(|errors| anyhow!("failed to resolve workflow settings: {errors}"))? + .run; + let working_directory = project_config::resolve_working_directory_from_run(&run_settings, cwd); + let goal_override = resolve_goal_override(&run_settings, &working_directory)?; + Ok((run_settings, working_directory, goal_override)) +} + /// Resolve the `run.goal` override for a direct (non-manifest) workflow /// run. Reads the file from disk if the goal layer is the `file` variant. /// Relative paths that survived config load (e.g. env-interpolated ones) /// are anchored at `working_directory`. fn resolve_goal_override( - settings: &SettingsLayer, + run_settings: &RunNamespace, working_directory: &Path, ) -> anyhow::Result> { - resolve_run_goal(settings, working_directory) + resolve_run_goal_from_namespace(run_settings, working_directory) .map(|opt| opt.map(|resolved| resolved.text)) .map_err(anyhow::Error::from) } @@ -176,4 +186,33 @@ 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::{RunGoalLayer, RunLayer}; + + 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: SettingsLayer { + run: Some(RunLayer { + goal: Some(RunGoalLayer::File { + file: InterpString::parse(&goal_path.display().to_string()), + }), + ..RunLayer::default() + }), + ..SettingsLayer::default() + }, + cwd: dir.path().to_path_buf(), + }) + .unwrap(); + + assert_eq!(resolved.goal_override.as_deref(), Some("dense goal")); + } } From 15cda5ab8a0b984584b656f40a15c1366612cbb7 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:24:50 -0400 Subject: [PATCH 26/60] keep workflow loader tolerant for invalid run settings --- .../fabro-workflow/src/operations/source.rs | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index 9aea42605..e7936a085 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -6,11 +6,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use anyhow::{Context, anyhow}; -use fabro_config::run::resolve_run_goal_from_namespace; -use fabro_config::{WorkflowSettingsBuilder, project as project_config}; +use anyhow::Context; +use fabro_config::project as project_config; +use fabro_config::run::resolve_run_goal; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::RunNamespace; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -70,8 +69,9 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; - let (_run_settings, working_directory, goal_override) = - resolve_runtime_run_settings(&settings, &request.cwd)?; + let working_directory = + project_config::resolve_working_directory(&settings, &request.cwd); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let current_dir = resolution .dot_path .parent() @@ -94,8 +94,9 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::DotSource { source, base_dir } => { let settings = request.settings; - let (_run_settings, working_directory, goal_override) = - resolve_runtime_run_settings(&settings, &request.cwd)?; + let working_directory = + project_config::resolve_working_directory(&settings, &request.cwd); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { raw_source: source, @@ -115,8 +116,9 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::Bundled(workflow) => { let settings = request.settings; - let (_run_settings, working_directory, goal_override) = - resolve_runtime_run_settings(&settings, &request.cwd)?; + let working_directory = + project_config::resolve_working_directory(&settings, &request.cwd); + let goal_override = resolve_goal_override(&settings, &working_directory)?; Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), @@ -133,27 +135,15 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } } -fn resolve_runtime_run_settings( - settings: &SettingsLayer, - cwd: &Path, -) -> anyhow::Result<(RunNamespace, PathBuf, Option)> { - let run_settings = WorkflowSettingsBuilder::from_layer(settings) - .map_err(|errors| anyhow!("failed to resolve workflow settings: {errors}"))? - .run; - let working_directory = project_config::resolve_working_directory_from_run(&run_settings, cwd); - let goal_override = resolve_goal_override(&run_settings, &working_directory)?; - Ok((run_settings, working_directory, goal_override)) -} - /// Resolve the `run.goal` override for a direct (non-manifest) workflow /// run. Reads the file from disk if the goal layer is the `file` variant. /// Relative paths that survived config load (e.g. env-interpolated ones) /// are anchored at `working_directory`. fn resolve_goal_override( - run_settings: &RunNamespace, + settings: &SettingsLayer, working_directory: &Path, ) -> anyhow::Result> { - resolve_run_goal_from_namespace(run_settings, working_directory) + resolve_run_goal(settings, working_directory) .map(|opt| opt.map(|resolved| resolved.text)) .map_err(anyhow::Error::from) } From 3667330e798e6f940f94806b8e5eb4aac79b2c32 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:28:56 -0400 Subject: [PATCH 27/60] cache dense run settings in command context --- lib/crates/fabro-cli/src/command_context.rs | 50 ++++++++++++++++++++- lib/crates/fabro-cli/src/commands/exec.rs | 19 +------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index d8156851c..552a56167 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,9 +2,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_config::{ServerSettingsBuilder, UserSettingsBuilder}; +use fabro_config::{ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder}; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_types::settings::{Combine, SettingsLayer}; +use fabro_types::settings::{Combine, RunNamespace, SettingsLayer}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -34,6 +34,7 @@ pub(crate) struct CommandContext { base_config_path: PathBuf, cli_layer: CliLayer, storage_dir: PathBuf, + run_settings: std::result::Result, server_settings: std::result::Result, user_settings: UserSettings, server_mode: ServerMode, @@ -42,6 +43,7 @@ pub(crate) struct CommandContext { struct ResolvedCommandSettings { storage_dir: PathBuf, + run_settings: std::result::Result, server_settings: std::result::Result, user_settings: UserSettings, } @@ -60,6 +62,7 @@ impl CommandContext { base_config_path, cli_layer: cli_layer.clone(), 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, @@ -106,6 +109,12 @@ impl CommandContext { .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 { &self.user_settings } @@ -163,6 +172,7 @@ impl CommandContext { base_config_path: self.base_config_path.clone(), cli_layer: self.cli_layer.clone(), 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, @@ -190,6 +200,13 @@ fn merge_settings_layer( cli_layer: &CliLayer, ) -> Result { let storage_dir = crate::local_server::storage_dir(&disk_settings)?; + let run_settings = WorkflowSettingsBuilder::from_layer(&disk_settings) + .map(|settings| settings.run) + .map_err(|err| { + // Keep command context tolerant even when unrelated run defaults + // do not resolve cleanly. + err.to_string() + }); let server_settings = ServerSettingsBuilder::from_layer(&disk_settings).map_err(|err| { // Keep storage-dir and CLI-target resolution tolerant even when full // server resolution would reject a partial local settings file. @@ -203,6 +220,7 @@ fn merge_settings_layer( let user_settings = UserSettingsBuilder::from_layer(&merged_settings)?; Ok(ResolvedCommandSettings { storage_dir, + run_settings, server_settings, user_settings, }) @@ -243,6 +261,7 @@ mod tests { base_config_path: PathBuf::from("/tmp/settings.toml"), cli_layer, 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, @@ -313,6 +332,11 @@ root = "/srv/fabro/default" 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()); } @@ -334,9 +358,31 @@ root = "/srv/fabro" .expect("settings should merge"); 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 = merge_settings_layer( + parse_settings_layer( + r#" +_version = 1 + +[run.agent.mcps.demo] +type = "stdio" +command = ["demo-mcp"] +"#, + ) + .expect("settings fixture should parse"), + &CliLayer::default(), + ) + .expect("settings should merge"); + + let run_settings = resolved.run_settings.expect("run settings should resolve"); + assert!(run_settings.agent.mcps.contains_key("demo")); + } + #[test] fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { let json_ctx = synthetic_context(true, Printer::Default); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index ca069da5c..b871b4056 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use anyhow::Result as AnyResult; use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; -use fabro_config::WorkflowSettingsBuilder; use fabro_llm::client::Client; use fabro_llm::error::{ Error as LlmError, ProviderErrorDetail, ProviderErrorKind, error_from_status_code, @@ -277,7 +276,6 @@ 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 provider_str = cli @@ -309,21 +307,8 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu let mcp_servers: Vec = if !cli.exec.agent.mcps.is_empty() { cli.exec.agent.mcps.values().cloned().collect() } else { - WorkflowSettingsBuilder::from_layer(&raw_settings) - .map(|settings| { - settings - .run - .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() - }) + ctx.run_settings() + .map(|settings| settings.agent.mcps.values().cloned().collect()) .unwrap_or_default() }; if let Some(target) = server_target { From cdec45cbc365a00d69fb619d0291832b89098c18 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:33:24 -0400 Subject: [PATCH 28/60] cache manifest run settings for system info --- lib/crates/fabro-server/src/server.rs | 159 +++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 16 deletions(-) diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index ee47d1cdf..39c130cbf 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -69,7 +69,7 @@ use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, ServerLayer, }; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::{InterpString, RunNamespace, SettingsLayer}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, @@ -576,6 +576,7 @@ pub struct AppState { pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, manifest_defaults: RwLock>, + manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, http_client: Option, @@ -659,6 +660,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()), @@ -794,12 +802,17 @@ impl AppState { pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { let resolved = Arc::new(ServerSettingsBuilder::from_layer(&settings)?); let manifest_defaults = Arc::new(run_manifest::manifest_defaults_layer(&settings)); + let manifest_run_settings = resolve_manifest_run_settings(manifest_defaults.as_ref()); resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; *self .manifest_defaults .write() .expect("manifest defaults lock poisoned") = manifest_defaults; + *self + .manifest_run_settings + .write() + .expect("manifest run settings lock poisoned") = manifest_run_settings; *self .server_settings .write() @@ -1330,7 +1343,7 @@ async fn get_system_info( _auth: AuthenticatedService, State(state): State>, ) -> Response { - let manifest_defaults = state.manifest_defaults(); + 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"); @@ -1364,10 +1377,10 @@ async fn get_system_info( total: Some(to_i64(total_runs)), active: Some(to_i64(active_runs)), }), - sandbox_provider: Some(system_sandbox_provider(manifest_defaults.as_ref())), + sandbox_provider: Some(system_sandbox_provider(&manifest_run_settings)), features: Some(system_features( server_settings.as_ref(), - manifest_defaults.as_ref(), + &manifest_run_settings, )), }; (StatusCode::OK, Json(response)).into_response() @@ -1375,11 +1388,12 @@ async fn get_system_info( fn system_features( server_settings: &ServerSettings, - manifest_defaults: &SettingsLayer, + manifest_run_settings: &std::result::Result, ) -> SystemFeatures { let session_sandboxes = server_settings.features.session_sandboxes; - let retros = WorkflowSettingsBuilder::from_layer(manifest_defaults) - .is_ok_and(|s| s.run.execution.retros); + let retros = manifest_run_settings + .as_ref() + .is_ok_and(|settings| settings.execution.retros); SystemFeatures { session_sandboxes: Some(session_sandboxes), retros: Some(retros), @@ -1671,10 +1685,20 @@ fn build_prune_plan( }) } -fn system_sandbox_provider(manifest_defaults: &SettingsLayer) -> String { - WorkflowSettingsBuilder::from_layer(manifest_defaults).map_or_else( +fn resolve_manifest_run_settings( + manifest_defaults: &SettingsLayer, +) -> std::result::Result { + WorkflowSettingsBuilder::from_layer(manifest_defaults) + .map(|settings| settings.run) + .map_err(|err| err.to_string()) +} + +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.run.sandbox.provider, + |settings| settings.sandbox.provider.clone(), ) } @@ -2599,11 +2623,13 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result Date: Thu, 23 Apr 2026 16:35:04 -0400 Subject: [PATCH 29/60] dedupe dense settings resolution in create --- .../fabro-workflow/src/operations/create.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 7fed063fc..d27f53005 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -16,7 +16,7 @@ use fabro_sandbox::daytona::detect_repo_info; use fabro_store::Database; use fabro_template::{TemplateContext, render as render_template}; use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::RunMode; +use fabro_types::settings::run::{RunMode, RunNamespace}; use fabro_types::{RunId, RunProvenance}; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -84,11 +84,12 @@ pub async fn create( cwd: request.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; + let settings = resolved.settings.clone(); + let resolved_settings = WorkflowSettingsBuilder::from_layer(&settings) + .map_err(|errors| Error::Precondition(errors.to_string()))?; - if WorkflowSettingsBuilder::from_layer(&resolved.settings).map_or(true, |settings| { - settings.run.execution.mode != RunMode::DryRun - }) { - validate_sandbox_provider(&resolved.settings)?; + if resolved_settings.run.execution.mode != RunMode::DryRun { + validate_sandbox_provider(&resolved_settings.run)?; } let CreateRunInput { @@ -107,9 +108,6 @@ pub async fn create( configured_providers, } = request; - let settings = resolved.settings.clone(); - let resolved_settings = WorkflowSettingsBuilder::from_layer(&settings) - .map_err(|errors| Error::Precondition(errors.to_string()))?; 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(); @@ -271,12 +269,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 = WorkflowSettingsBuilder::from_layer(settings) - .map_err(|errors| Error::Precondition(errors.to_string()))?; - resolved - .run - .sandbox +fn validate_sandbox_provider(run: &RunNamespace) -> Result<(), Error> { + run.sandbox .provider .parse::() .map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))?; From 134d8c32d51af6ae13eabd58fb5896d1f1fc6c20 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:37:00 -0400 Subject: [PATCH 30/60] add dense run settings builder --- lib/crates/fabro-cli/src/command_context.rs | 14 +++--- lib/crates/fabro-config/src/builders.rs | 54 +++++++++++++++++++++ lib/crates/fabro-config/src/lib.rs | 3 +- lib/crates/fabro-server/src/server.rs | 6 +-- 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 552a56167..b52d02515 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_config::{ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder}; +use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder}; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_types::settings::{Combine, RunNamespace, SettingsLayer}; use fabro_types::{ServerSettings, UserSettings}; @@ -200,13 +200,11 @@ fn merge_settings_layer( cli_layer: &CliLayer, ) -> Result { let storage_dir = crate::local_server::storage_dir(&disk_settings)?; - let run_settings = WorkflowSettingsBuilder::from_layer(&disk_settings) - .map(|settings| settings.run) - .map_err(|err| { - // Keep command context tolerant even when unrelated run defaults - // do not resolve cleanly. - err.to_string() - }); + let run_settings = RunSettingsBuilder::from_layer(&disk_settings).map_err(|err| { + // Keep command context tolerant even when unrelated run defaults + // do not resolve cleanly. + err.to_string() + }); let server_settings = ServerSettingsBuilder::from_layer(&disk_settings).map_err(|err| { // Keep storage-dir and CLI-target resolution tolerant even when full // server resolution would reject a partial local settings file. diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index d5c783d39..53e7e65a7 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -125,6 +125,33 @@ impl UserSettingsBuilder { } } +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 = parse_settings_layer(source) + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer(&layer) + } + + pub 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) + } +} + #[derive(Clone, Debug, Default)] pub struct WorkflowSettingsBuilder { args: SettingsLayer, @@ -281,3 +308,30 @@ fn finish_dense_result( Err(errors.into()) } } + +#[cfg(test)] +mod tests { + use fabro_types::settings::run::RunMode; + + use super::RunSettingsBuilder; + + #[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")); + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 593caa489..f438e7990 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -25,7 +25,8 @@ pub mod user; use std::path::Path; pub use builders::{ - ResolveErrors, ServerSettingsBuilder, UserSettingsBuilder, WorkflowSettingsBuilder, + ResolveErrors, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, + WorkflowSettingsBuilder, }; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 39c130cbf..7ddcb0c8f 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::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{ServerSettingsBuilder, Storage, WorkflowSettingsBuilder}; +use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, Storage}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -1688,9 +1688,7 @@ fn build_prune_plan( fn resolve_manifest_run_settings( manifest_defaults: &SettingsLayer, ) -> std::result::Result { - WorkflowSettingsBuilder::from_layer(manifest_defaults) - .map(|settings| settings.run) - .map_err(|err| err.to_string()) + RunSettingsBuilder::from_layer(manifest_defaults).map_err(|err| err.to_string()) } fn system_sandbox_provider( From eaca3daac46da3bc61a7b2d0f9ae8ea405a908ce Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:48:07 -0400 Subject: [PATCH 31/60] cache dense workflow settings in workflow loader --- .../fabro-workflow/src/operations/create.rs | 15 +++--- .../fabro-workflow/src/operations/source.rs | 49 ++++++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index d27f53005..56c820100 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -84,13 +84,14 @@ pub async fn create( cwd: request.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; + let labels = { + let resolved_settings = resolved.workflow_settings().map_err(Error::Precondition)?; + if resolved_settings.run.execution.mode != RunMode::DryRun { + validate_sandbox_provider(&resolved_settings.run)?; + } + resolved_settings.combined_labels() + }; let settings = resolved.settings.clone(); - let resolved_settings = WorkflowSettingsBuilder::from_layer(&settings) - .map_err(|errors| Error::Precondition(errors.to_string()))?; - - if resolved_settings.run.execution.mode != RunMode::DryRun { - validate_sandbox_provider(&resolved_settings.run)?; - } let CreateRunInput { workflow: _, @@ -148,7 +149,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, diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index e7936a085..2105725ec 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -7,8 +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_config::{WorkflowSettingsBuilder, project as project_config}; +use fabro_types::WorkflowSettings; use fabro_types::settings::SettingsLayer; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; @@ -35,6 +36,7 @@ pub(crate) struct ResolveWorkflowInput { pub(crate) struct ResolvedWorkflow { pub raw_source: String, pub settings: SettingsLayer, + workflow_settings: std::result::Result, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, @@ -44,6 +46,12 @@ pub(crate) struct ResolvedWorkflow { pub working_directory: PathBuf, } +impl ResolvedWorkflow { + pub(crate) fn workflow_settings(&self) -> std::result::Result<&WorkflowSettings, String> { + self.workflow_settings.as_ref().map_err(Clone::clone) + } +} + fn workflow_slug_from_path(workflow_path: &Path) -> Option { let file_name = workflow_path.file_name()?.to_string_lossy(); if workflow_path.extension().is_none() { @@ -67,6 +75,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< WorkflowInput::Path(workflow_path) => { let resolution = project_config::resolve_workflow_path(&workflow_path, &request.cwd)?; let settings = request.settings; + let workflow_settings = resolve_dense_workflow_settings(&settings); let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; let working_directory = @@ -81,6 +90,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< Ok(ResolvedWorkflow { raw_source, settings, + workflow_settings, workflow_slug: resolution.workflow_slug, workflow_toml_path: resolution.workflow_toml_path, dot_path: Some(resolution.dot_path.clone()), @@ -94,6 +104,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::DotSource { source, base_dir } => { let settings = request.settings; + let workflow_settings = resolve_dense_workflow_settings(&settings); let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); let goal_override = resolve_goal_override(&settings, &working_directory)?; @@ -101,6 +112,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< Ok(ResolvedWorkflow { raw_source: source, settings, + workflow_settings, workflow_slug: None, workflow_toml_path: None, dot_path: None, @@ -116,6 +128,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::Bundled(workflow) => { let settings = request.settings; + let workflow_settings = resolve_dense_workflow_settings(&settings); let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); let goal_override = resolve_goal_override(&settings, &working_directory)?; @@ -123,6 +136,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), settings, + workflow_settings, workflow_slug: workflow_slug_from_path(&workflow.logical_path), workflow_toml_path: None, dot_path: Some(workflow.logical_path.clone()), @@ -135,6 +149,12 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } } +fn resolve_dense_workflow_settings( + settings: &SettingsLayer, +) -> std::result::Result { + WorkflowSettingsBuilder::from_layer(settings).map_err(|err| err.to_string()) +} + /// Resolve the `run.goal` override for a direct (non-manifest) workflow /// run. Reads the file from disk if the goal layer is the `file` variant. /// Relative paths that survived config load (e.g. env-interpolated ones) @@ -205,4 +225,31 @@ mod tests { assert_eq!(resolved.goal_override.as_deref(), Some("dense goal")); } + + #[test] + fn resolve_workflow_keeps_invalid_workflow_settings_tolerant() { + use fabro_types::settings::run::{RunLayer, RunSandboxLayer}; + + let dir = tempfile::tempdir().unwrap(); + let resolved = resolve_workflow(ResolveWorkflowInput { + workflow: WorkflowInput::DotSource { + source: "digraph Test { start -> exit }".to_string(), + base_dir: None, + }, + settings: SettingsLayer { + run: Some(RunLayer { + sandbox: Some(RunSandboxLayer { + provider: Some("not-a-provider".to_string()), + ..RunSandboxLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsLayer::default() + }, + cwd: dir.path().to_path_buf(), + }) + .unwrap(); + + assert!(resolved.workflow_settings().is_err()); + } } From 8d472bb6ed72ed2f89a4e6e30f81c23d2b1511d7 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:51:09 -0400 Subject: [PATCH 32/60] use run settings builder in manifest preflight --- lib/crates/fabro-server/src/run_manifest.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index a12ca2842..768a97b6c 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::{RunSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; @@ -389,12 +389,12 @@ async fn build_preflight_report( &configured_providers, ); let resolved_run = - WorkflowSettingsBuilder::from_layer(&materialized).map_err(|errors| anyhow!(errors))?; + RunSettingsBuilder::from_layer(&materialized).map_err(anyhow::Error::from)?; let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; - let sandbox_provider = resolve_sandbox_provider(&resolved_run.run)?; + let sandbox_provider = resolve_sandbox_provider(&resolved_run)?; let sandbox_provider = - if resolved_run.run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { + if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { SandboxProvider::Local } else { sandbox_provider @@ -414,7 +414,7 @@ async fn build_preflight_report( &mut checks, sandbox_provider, prepared, - &resolved_run.run, + &resolved_run, github_app.clone(), daytona_api_key, ) @@ -423,7 +423,7 @@ async fn build_preflight_report( state, &mut checks, graph, - &resolved_run.run, + &resolved_run, &configured_providers, ) .await; From 96b904c24a5a24e2c0facdf57fd572321dc2b94f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 16:59:56 -0400 Subject: [PATCH 33/60] switch workflow operations to dense settings --- lib/crates/fabro-server/src/run_manifest.rs | 13 +- .../src/handler/manager_loop.rs | 5 +- .../fabro-workflow/src/operations/create.rs | 146 ++++++++---------- .../fabro-workflow/src/operations/source.rs | 91 ++++------- .../fabro-workflow/src/operations/start.rs | 22 +-- .../fabro-workflow/src/operations/validate.rs | 4 +- .../fabro-workflow/src/run_materialization.rs | 40 ++--- .../fabro-workflow/tests/materialize_run.rs | 42 +++-- 8 files changed, 153 insertions(+), 210 deletions(-) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 768a97b6c..7222efdc5 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::{RunSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; +use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; @@ -41,7 +41,6 @@ pub(crate) struct PreparedManifest { pub root_source: String, pub run_id: Option, pub settings: WorkflowSettings, - pub settings_layer: SettingsLayer, pub target_path: PathBuf, pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, @@ -114,7 +113,6 @@ pub(crate) fn prepare_manifest( .transpose() .map_err(|err| anyhow!("invalid run ID: {err}"))?, settings: settings.clone(), - settings_layer: settings_layer.clone(), target_path, workflow_bundle, workflow_input, @@ -127,7 +125,7 @@ pub(crate) fn validate_prepared_manifest( ) -> Result { validate(ValidateInput { workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()), - settings: prepared.settings_layer.clone(), + settings: prepared.settings.clone(), cwd: prepared.cwd.clone(), custom_transforms: Vec::new(), }) @@ -139,7 +137,7 @@ pub(crate) fn create_run_input( ) -> CreateRunInput { CreateRunInput { workflow: WorkflowInput::Bundled(prepared.workflow_input), - settings: prepared.settings_layer, + settings: prepared.settings, cwd: prepared.cwd, workflow_slug: None, workflow_path: Some(prepared.target_path), @@ -383,13 +381,12 @@ async fn build_preflight_report( let configured_providers = state.provider_credentials.configured_providers().await; let materialized = materialize_run( - prepared.settings_layer.clone(), + prepared.settings.clone(), graph, Catalog::builtin(), &configured_providers, ); - let resolved_run = - RunSettingsBuilder::from_layer(&materialized).map_err(anyhow::Error::from)?; + 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)?; diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 589962f43..d6d8c98f4 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -8,7 +8,6 @@ use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_store::{ArtifactStore, Database}; use fabro_types::WorkflowSettings; -use fabro_types::settings::SettingsLayer; use object_store::memory::InMemory; use tokio::fs; use tokio::time::{sleep, timeout}; @@ -74,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,13 +83,10 @@ pub async fn create( cwd: request.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; - let labels = { - let resolved_settings = resolved.workflow_settings().map_err(Error::Precondition)?; - if resolved_settings.run.execution.mode != RunMode::DryRun { - validate_sandbox_provider(&resolved_settings.run)?; - } - resolved_settings.combined_labels() - }; + 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 { @@ -309,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); @@ -333,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() } @@ -374,8 +368,6 @@ fn persist_validated( Catalog::builtin(), &configured_providers, ); - let settings = WorkflowSettingsBuilder::from_layer(&settings) - .map_err(|errors| Error::Precondition(errors.to_string()))?; let run_id = run_id.unwrap_or_else(RunId::new); let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id)); @@ -414,10 +406,11 @@ mod tests { use std::time::Duration; use chrono::{Local, TimeZone, Utc}; + use fabro_config::WorkflowSettingsBuilder; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; - use fabro_types::fixtures; - use fabro_types::settings::InterpString; + use fabro_types::settings::{InterpString, SettingsLayer}; + use fabro_types::{WorkflowSettings, fixtures}; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; @@ -433,11 +426,16 @@ mod tests { )) } - fn test_default_settings() -> SettingsLayer { - SettingsLayer::test_default() + fn settings_from_layer(mut layer: SettingsLayer) -> WorkflowSettings { + layer.ensure_test_auth_methods(); + WorkflowSettingsBuilder::from_layer(&layer).expect("settings should resolve") } - fn validate_dot(dot_source: &str, settings: SettingsLayer) -> Validated { + fn test_default_settings() -> WorkflowSettings { + settings_from_layer(SettingsLayer::test_default()) + } + + fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { validate(ValidateInput { workflow: WorkflowInput::DotSource { source: dot_source.to_string(), @@ -459,7 +457,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"); @@ -476,7 +474,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"] @@ -514,7 +512,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!( @@ -532,19 +530,22 @@ 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 { - goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), - inputs: Some(inputs), - ..RunLayer::default() - }), - ..SettingsLayer::default() - } - }); + let validated = validate_dot( + dot, + settings_from_layer({ + 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 { + 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"); @@ -563,7 +564,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(), }); @@ -576,7 +577,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()); @@ -606,7 +607,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)], }) @@ -639,7 +640,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(), }) @@ -678,7 +679,7 @@ mod tests { ), ]), }), - settings: SettingsLayer::default(), + settings: WorkflowSettings::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }) @@ -749,25 +750,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, @@ -788,7 +774,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:?}"), @@ -807,7 +793,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: { + settings: settings_from_layer({ use fabro_types::settings::ReplaceMap; use fabro_types::settings::run::{ RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, @@ -815,7 +801,7 @@ mod tests { }; let mut metadata = HashMap::new(); metadata.insert("env".to_string(), "test".to_string()); - let mut layer = SettingsLayer { + let layer = SettingsLayer { run: Some(RunLayer { goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), metadata: ReplaceMap::from(metadata), @@ -835,9 +821,8 @@ mod tests { }), ..SettingsLayer::default() }; - layer.ensure_test_auth_methods(); layer - }, + }), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, @@ -936,9 +921,9 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: { + settings: settings_from_layer({ use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let mut layer = SettingsLayer { + let layer = SettingsLayer { run: Some(RunLayer { working_dir: Some(InterpString::parse("workspace")), execution: Some(RunExecutionLayer { @@ -949,9 +934,8 @@ mod tests { }), ..SettingsLayer::default() }; - layer.ensure_test_auth_methods(); layer - }, + }), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -1019,9 +1003,9 @@ mod tests { ); } - fn dry_run_only_settings() -> SettingsLayer { + fn dry_run_only_settings() -> WorkflowSettings { use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let mut layer = SettingsLayer { + settings_from_layer(SettingsLayer { run: Some(RunLayer { execution: Some(RunExecutionLayer { mode: Some(RunMode::DryRun), @@ -1030,15 +1014,13 @@ mod tests { ..RunLayer::default() }), ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + }) } - fn dry_run_with_storage(storage_dir: &Path) -> SettingsLayer { + fn dry_run_with_storage(storage_dir: &Path) -> WorkflowSettings { use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; - let mut layer = SettingsLayer { + settings_from_layer(SettingsLayer { run: Some(RunLayer { execution: Some(RunExecutionLayer { mode: Some(RunMode::DryRun), @@ -1053,9 +1035,7 @@ mod tests { ..ServerLayer::default() }), ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + }) } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index 2105725ec..e0bc5d969 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -7,10 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; -use fabro_config::run::resolve_run_goal; -use fabro_config::{WorkflowSettingsBuilder, project as project_config}; +use fabro_config::project::resolve_working_directory_from_run; +use fabro_config::run::resolve_run_goal_from_namespace; use fabro_types::WorkflowSettings; -use fabro_types::settings::SettingsLayer; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -28,15 +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, - workflow_settings: std::result::Result, + pub settings: WorkflowSettings, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, @@ -46,12 +44,6 @@ pub(crate) struct ResolvedWorkflow { pub working_directory: PathBuf, } -impl ResolvedWorkflow { - pub(crate) fn workflow_settings(&self) -> std::result::Result<&WorkflowSettings, String> { - self.workflow_settings.as_ref().map_err(Clone::clone) - } -} - fn workflow_slug_from_path(workflow_path: &Path) -> Option { let file_name = workflow_path.file_name()?.to_string_lossy(); if workflow_path.extension().is_none() { @@ -73,13 +65,12 @@ 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 = + fabro_config::project::resolve_workflow_path(&workflow_path, &request.cwd)?; let settings = request.settings; - let workflow_settings = resolve_dense_workflow_settings(&settings); let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; - 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 current_dir = resolution .dot_path @@ -90,7 +81,6 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< Ok(ResolvedWorkflow { raw_source, settings, - workflow_settings, workflow_slug: resolution.workflow_slug, workflow_toml_path: resolution.workflow_toml_path, dot_path: Some(resolution.dot_path.clone()), @@ -104,15 +94,12 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::DotSource { source, base_dir } => { let settings = request.settings; - let workflow_settings = resolve_dense_workflow_settings(&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 { raw_source: source, settings, - workflow_settings, workflow_slug: None, workflow_toml_path: None, dot_path: None, @@ -128,15 +115,12 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } WorkflowInput::Bundled(workflow) => { let settings = request.settings; - let workflow_settings = resolve_dense_workflow_settings(&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 { raw_source: workflow.source.clone(), settings, - workflow_settings, workflow_slug: workflow_slug_from_path(&workflow.logical_path), workflow_toml_path: None, dot_path: Some(workflow.logical_path.clone()), @@ -149,21 +133,15 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } } -fn resolve_dense_workflow_settings( - settings: &SettingsLayer, -) -> std::result::Result { - WorkflowSettingsBuilder::from_layer(settings).map_err(|err| err.to_string()) -} - /// Resolve the `run.goal` override for a direct (non-manifest) workflow /// run. Reads the file from disk if the goal layer is the `file` variant. /// Relative paths that survived config load (e.g. env-interpolated ones) /// are anchored at `working_directory`. fn resolve_goal_override( - settings: &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) } @@ -175,7 +153,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 { @@ -183,12 +161,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(), }) @@ -200,7 +178,7 @@ mod tests { #[test] fn resolve_workflow_reads_goal_override_from_dense_run_settings() { use fabro_types::settings::InterpString; - use fabro_types::settings::run::{RunGoalLayer, RunLayer}; + use fabro_types::settings::run::{RunGoal, RunNamespace}; let dir = tempfile::tempdir().unwrap(); let goal_path = dir.path().join("goal.md"); @@ -210,14 +188,14 @@ mod tests { source: "digraph Test { start -> exit }".to_string(), base_dir: None, }, - settings: SettingsLayer { - run: Some(RunLayer { - goal: Some(RunGoalLayer::File { - file: InterpString::parse(&goal_path.display().to_string()), - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() + 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(), }) @@ -227,29 +205,18 @@ mod tests { } #[test] - fn resolve_workflow_keeps_invalid_workflow_settings_tolerant() { - use fabro_types::settings::run::{RunLayer, RunSandboxLayer}; - + 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: SettingsLayer { - run: Some(RunLayer { - sandbox: Some(RunSandboxLayer { - provider: Some("not-a-provider".to_string()), - ..RunSandboxLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }, + settings: WorkflowSettings::default(), cwd: dir.path().to_path_buf(), }) .unwrap(); - assert!(resolved.workflow_settings().is_err()); + 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 7ab032436..e5d02af55 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -969,10 +969,11 @@ mod tests { use std::time::Duration; use chrono::Utc; + use fabro_config::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::{WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; @@ -1011,6 +1012,11 @@ mod tests { (storage_root, run_dir) } + fn settings_from_layer(mut layer: SettingsLayer) -> WorkflowSettings { + layer.ensure_test_auth_methods(); + WorkflowSettingsBuilder::from_layer(&layer).expect("settings should resolve") + } + async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc) { let store = memory_store(); let created = crate::operations::create( @@ -1020,8 +1026,8 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: { - let mut layer = SettingsLayer { + settings: settings_from_layer({ + let layer = SettingsLayer { run: Some(RunLayer { execution: Some(RunExecutionLayer { mode: Some(RunMode::DryRun), @@ -1031,9 +1037,8 @@ mod tests { }), ..SettingsLayer::default() }; - layer.ensure_test_auth_methods(); layer - }, + }), cwd: storage_root .parent() .unwrap_or_else(|| Path::new(".")) @@ -1208,8 +1213,8 @@ mod tests { .unwrap() .clone(), ), - settings: { - let mut layer = SettingsLayer { + settings: settings_from_layer({ + let layer = SettingsLayer { run: Some(RunLayer { execution: Some(RunExecutionLayer { mode: Some(RunMode::DryRun), @@ -1219,9 +1224,8 @@ mod tests { }), ..SettingsLayer::default() }; - layer.ensure_test_auth_methods(); layer - }, + }), 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/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/tests/materialize_run.rs b/lib/crates/fabro-workflow/tests/materialize_run.rs index c15702e70..9c3ff23d1 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/fabro-workflow/tests/materialize_run.rs @@ -1,9 +1,9 @@ -use fabro_config::WorkflowSettingsBuilder; 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 { @@ -19,25 +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 = WorkflowSettingsBuilder::from_layer(&materialized) - .unwrap() - .run; + let resolved = &materialized.run; assert_eq!( resolved @@ -58,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()); } @@ -74,14 +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 = WorkflowSettingsBuilder::from_layer(&materialized) - .unwrap() - .run; + let resolved = &materialized.run; assert_eq!( resolved From 83fc1602ea3d0549dc77d529d2f28bd2b49da9f9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:09:21 -0400 Subject: [PATCH 34/60] route cli settings loads through dense config --- lib/crates/fabro-cli/src/command_context.rs | 124 ++++++++---------- lib/crates/fabro-cli/src/local_server.rs | 70 +++++----- lib/crates/fabro-cli/src/user_config.rs | 137 ++++++++++++++------ 3 files changed, 185 insertions(+), 146 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index b52d02515..289ba63b7 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,9 +2,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder}; +use fabro_types::settings::RunNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_types::settings::{Combine, RunNamespace, SettingsLayer}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -13,6 +12,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)] @@ -185,42 +185,28 @@ fn load_merged_settings( cli_layer: &CliLayer, server_mode: &ServerMode, ) -> Result { - let disk_settings = match server_mode { - ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, + 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) + resolve_command_settings(loaded_settings) } -fn merge_settings_layer( - disk_settings: SettingsLayer, - cli_layer: &CliLayer, -) -> Result { - let storage_dir = crate::local_server::storage_dir(&disk_settings)?; - let run_settings = RunSettingsBuilder::from_layer(&disk_settings).map_err(|err| { - // Keep command context tolerant even when unrelated run defaults - // do not resolve cleanly. - err.to_string() - }); - let server_settings = ServerSettingsBuilder::from_layer(&disk_settings).map_err(|err| { - // Keep storage-dir and CLI-target resolution tolerant even when full - // server resolution would reject a partial local settings file. - err.to_string() - }); - let merged_settings = SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - } - .combine(disk_settings); - let user_settings = UserSettingsBuilder::from_layer(&merged_settings)?; +fn resolve_command_settings(loaded_settings: LoadedSettings) -> Result { Ok(ResolvedCommandSettings { - storage_dir, - run_settings, - server_settings, - user_settings, + storage_dir: loaded_settings.storage_dir, + run_settings: loaded_settings.run_settings, + server_settings: loaded_settings.server_settings, + user_settings: loaded_settings.user_settings, }) } @@ -228,14 +214,12 @@ fn merge_settings_layer( mod tests { use std::path::PathBuf; - use fabro_config::parse_settings_layer; - use fabro_types::settings::InterpString; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; 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 { @@ -249,9 +233,11 @@ mod tests { fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { let cli_layer = cli_layer_with_json_and_verbose(); - let resolved_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"), + ) + .expect("settings should merge"); CommandContext { printer, process_local_json, @@ -267,18 +253,6 @@ mod tests { } } - fn with_storage_dir_override( - mut layer: fabro_types::settings::SettingsLayer, - path: &std::path::Path, - ) -> fabro_types::settings::SettingsLayer { - 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(&path.display().to_string())); - layer - } - #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); @@ -295,24 +269,34 @@ 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" "#, + None, + Some(&cli_layer), + ) + .expect("base settings should resolve"), ) - .expect("settings fixture should parse"); - let override_disk_settings = with_storage_dir_override( - base_disk_settings.clone(), - std::path::Path::new("/srv/fabro/override"), - ); + .expect("base settings should merge"); + let connection_settings = resolve_command_settings( + user_config::load_resolved_settings_from_toml( + r#" +_version = 1 - let base_settings = merge_settings_layer(base_disk_settings, &cli_layer) - .expect("base settings should merge"); - let connection_settings = merge_settings_layer(override_disk_settings, &cli_layer) - .expect("connection settings should merge"); +[server.storage] +root = "/srv/fabro/default" +"#, + Some(std::path::Path::new("/srv/fabro/override")), + Some(&cli_layer), + ) + .expect("connection settings should resolve"), + ) + .expect("connection settings should merge"); assert_eq!( base_settings.user_settings, @@ -341,17 +325,18 @@ root = "/srv/fabro/default" #[test] fn storage_dir_stays_available_when_server_settings_do_not_resolve() { - let resolved = merge_settings_layer( - parse_settings_layer( + 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 fixture should parse"), - &CliLayer::default(), + .expect("settings should resolve"), ) .expect("settings should merge"); @@ -362,8 +347,8 @@ root = "/srv/fabro" #[test] fn run_settings_include_run_agent_mcps() { - let resolved = merge_settings_layer( - parse_settings_layer( + let resolved = resolve_command_settings( + user_config::load_resolved_settings_from_toml( r#" _version = 1 @@ -371,9 +356,10 @@ _version = 1 type = "stdio" command = ["demo-mcp"] "#, + None, + Some(&CliLayer::default()), ) - .expect("settings fixture should parse"), - &CliLayer::default(), + .expect("settings should resolve"), ) .expect("settings should merge"); diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 2dcb35629..17f4599dd 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -1,14 +1,9 @@ //! 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::{Path, PathBuf}; use anyhow::Result; use fabro_config::bind::BindRequest; -use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::ServerSettings; use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; @@ -23,34 +18,27 @@ pub(crate) struct LocalServerConfig { impl LocalServerConfig { pub(crate) fn load(config_path: Option<&Path>, storage_dir: Option<&Path>) -> Result { - let settings = - user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?; - Self::from_layer(&settings) + 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_settings_with_storage_dir(storage_dir)?; - Self::from_layer(&settings) + let settings = user_config::load_resolved_settings(None, storage_dir, None)?; + Ok(Self::from_loaded_settings(settings)) } - fn from_layer(settings: &SettingsLayer) -> Result { - let storage_dir = storage_dir(settings)?; - let config_log_level = settings - .server - .as_ref() - .and_then(|server| server.logging.as_ref()) - .and_then(|logging| logging.level.clone()); - let server_settings = resolved_server_settings(settings).map_err(|err| err.to_string()); + 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(); - Ok(Self { - storage_dir, + Self { + storage_dir: settings.storage_dir, auth_methods, - config_log_level, + config_log_level: settings.config_log_level, server_settings, - }) + } } pub(crate) fn storage_dir(&self) -> &Path { @@ -75,20 +63,16 @@ impl LocalServerConfig { } pub(crate) fn storage_dir_from_toml(source: &str) -> Result { - let settings = parse_settings_layer(source) - .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; - storage_dir(&settings) + storage_dir_from_toml_with_lookup(source, &|name| std::env::var(name).ok()) } -pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { - storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) -} - -pub(crate) fn storage_dir_with_lookup( - settings: &SettingsLayer, +fn storage_dir_from_toml_with_lookup( + source: &str, lookup: &dyn Fn(&str) -> Option, ) -> Result { - let storage_root = settings + let layer: SettingsLayer = toml::from_str(source) + .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; + let storage_root = layer .server .as_ref() .and_then(|server| server.storage.as_ref()) @@ -104,15 +88,11 @@ pub(crate) fn storage_dir_with_lookup( Ok(PathBuf::from(resolved_root.value)) } -fn resolved_server_settings(settings: &SettingsLayer) -> Result { - ServerSettingsBuilder::from_layer(settings).map_err(Into::into) -} - #[cfg(test)] mod tests { use std::path::PathBuf; - use super::storage_dir_from_toml; + 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() { @@ -135,4 +115,20 @@ root = "/srv/fabro" assert_eq!(path, fabro_config::user::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/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index b9c28e3f0..ea7b304de 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,52 +1,101 @@ -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::{active_settings_path, default_storage_dir}; use fabro_config::user::{default_socket_path, load_settings_config}; -use fabro_types::UserSettings; -use fabro_types::settings::cli::CliTargetSettings; -use fabro_types::settings::{CliNamespace, SettingsLayer}; +use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder}; +use fabro_types::settings::cli::{CliLayer, CliTargetSettings}; +use fabro_types::settings::{CliNamespace, Combine, RunNamespace, SettingsLayer}; +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 { + cli_layer: Option<&CliLayer>, +) -> anyhow::Result { let layer = load_settings_config(config_path)?; - Ok(apply_storage_dir_override(layer, storage_dir)) + resolve_loaded_settings(layer, storage_dir, cli_layer) } -fn apply_storage_dir_override( - mut layer: SettingsLayer, +fn resolve_loaded_settings( + layer: SettingsLayer, storage_dir: Option<&Path>, -) -> SettingsLayer { - use fabro_types::settings::InterpString; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; + cli_layer: Option<&CliLayer>, +) -> anyhow::Result { + let storage_override = storage_dir.map(Path::to_path_buf); + let storage_dir = storage_dir_from_layer(&layer, storage_dir)?; + let config_log_level = layer + .server + .as_ref() + .and_then(|server| server.logging.as_ref()) + .and_then(|logging| logging.level.clone()); + let run_settings = RunSettingsBuilder::from_layer(&layer).map_err(|err| err.to_string()); + let server_settings = ServerSettingsBuilder::from_layer(&layer) + .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_layer = if let Some(cli_layer) = cli_layer { + SettingsLayer { + cli: Some(cli_layer.clone()), + ..SettingsLayer::default() + } + .combine(layer.clone()) + } else { + layer.clone() + }; + let user_settings = UserSettingsBuilder::from_layer(&user_settings_layer)?; + Ok(LoadedSettings { + storage_dir, + config_log_level, + run_settings, + server_settings, + user_settings, + }) +} + +fn storage_dir_from_layer( + layer: &SettingsLayer, + storage_dir: Option<&Path>, +) -> anyhow::Result { + storage_dir_from_layer_with_lookup(layer, storage_dir, &|name| std::env::var(name).ok()) +} + +fn storage_dir_from_layer_with_lookup( + layer: &SettingsLayer, + storage_dir: Option<&Path>, + lookup: &dyn Fn(&str) -> Option, +) -> anyhow::Result { 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())); + return Ok(dir.to_path_buf()); } - layer + let storage_root = layer + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.clone()) + .unwrap_or_else(|| { + fabro_types::settings::InterpString::parse(&default_storage_dir().to_string_lossy()) + }); + let resolved_root = storage_root.resolve(lookup)?; + Ok(PathBuf::from(resolved_root.value)) } /// Pull the resolved CLI target configuration out of `[cli.target]`. @@ -102,17 +151,27 @@ 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 layer: SettingsLayer = toml::from_str(source) + .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; + resolve_loaded_settings(layer, storage_dir, cli_layer) +} + #[cfg(test)] mod tests { use std::path::PathBuf; + use fabro_config::UserSettingsBuilder; use fabro_config::user::default_storage_dir; - use fabro_config::{UserSettingsBuilder, parse_settings_layer}; use fabro_types::UserSettings; use super::*; use crate::args::ServerTargetArgs; - use crate::local_server; fn server_target_args(value: Option<&str>) -> ServerTargetArgs { ServerTargetArgs { @@ -124,10 +183,6 @@ mod tests { UserSettingsBuilder::from_toml(source).expect("fixture should resolve") } - fn parse_layer(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") - } - #[test] fn exec_has_no_server_target_by_default() { assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None); @@ -234,45 +289,47 @@ url = "https://config.example.com" #[test] fn storage_dir_defaults_without_server_auth_methods() { - let settings = SettingsLayer::default(); + let layer = SettingsLayer::default(); assert_eq!( - local_server::storage_dir(&settings).unwrap(), + storage_dir_from_layer(&layer, None).unwrap(), default_storage_dir() ); } #[test] fn storage_dir_uses_explicit_server_storage_root() { - let settings = parse_layer( + let layer: SettingsLayer = 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_layer(&layer, None).unwrap(), PathBuf::from("/srv/fabro") ); } #[test] fn storage_dir_resolves_env_interpolated_root() { - let settings = parse_layer( + let layer: SettingsLayer = 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_layer_with_lookup(&layer, None, &|name| { (name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string()) }) .unwrap(), From 31dc4a78f76c2774d8e81b802af9f73c7db75ae1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:13:09 -0400 Subject: [PATCH 35/60] route app state reload through dense server settings --- lib/crates/fabro-server/src/serve.rs | 18 ++++-- lib/crates/fabro-server/src/server.rs | 79 +++++++++++++++++++-------- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index ca1006e2d..902d4d667 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -34,7 +34,8 @@ use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECR 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, + reconcile_incomplete_runs_on_startup, resolve_app_state_settings, shutdown_active_workers, + spawn_scheduler, }; use crate::server_secrets::{ServerSecrets, process_env_snapshot}; use crate::startup::resolve_startup; @@ -618,15 +619,18 @@ where 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)?; + let resolved_app_settings = resolve_app_state_settings(&effective_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 bind_request = resolve_bind_request_from_server_settings( + &resolved_app_settings.server_settings, + args.bind.as_deref(), + )?; let shared_settings = Arc::new(RwLock::new(effective_settings)); std::fs::create_dir_all(&data_dir) .with_context(|| format!("creating data directory {}", data_dir.display()))?; @@ -657,7 +661,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, @@ -753,7 +757,9 @@ where *cfg != effective }; if changed { - match state_for_poll.replace_settings(effective.clone()) { + match resolve_app_state_settings(&effective) + .and_then(|resolved| state_for_poll.replace_runtime_settings(resolved)) + { Ok(()) => { *shared_settings_for_poll .write() diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 7ddcb0c8f..8ebdeb820 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -587,7 +587,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, @@ -598,6 +598,13 @@ pub(crate) struct AppStateConfig { pub(crate) http_client: Option, } +#[derive(Clone)] +pub(crate) struct ResolvedAppStateSettings { + pub(crate) server_settings: ServerSettings, + pub(crate) manifest_defaults: SettingsLayer, + pub(crate) manifest_run_settings: std::result::Result, +} + fn nonzero_i64(value: i64) -> Option { (value != 0).then_some(value) } @@ -799,11 +806,19 @@ impl AppState { self.shutting_down.load(Ordering::Relaxed) } - pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { - let resolved = Arc::new(ServerSettingsBuilder::from_layer(&settings)?); - let manifest_defaults = Arc::new(run_manifest::manifest_defaults_layer(&settings)); - let manifest_run_settings = resolve_manifest_run_settings(manifest_defaults.as_ref()); - 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_defaults, + manifest_run_settings, + } = resolved_settings; + let server_settings = Arc::new(server_settings); + let manifest_defaults = Arc::new(manifest_defaults); + resolve_canonical_origin(&server_settings.server, &self.env_lookup) + .map_err(anyhow::Error::msg)?; *self .manifest_defaults @@ -816,7 +831,7 @@ impl AppState { *self .server_settings .write() - .expect("server settings lock poisoned") = resolved; + .expect("server settings lock poisoned") = server_settings; Ok(()) } } @@ -1691,6 +1706,17 @@ fn resolve_manifest_run_settings( RunSettingsBuilder::from_layer(manifest_defaults).map_err(|err| err.to_string()) } +pub(crate) fn resolve_app_state_settings( + layer: &SettingsLayer, +) -> anyhow::Result { + let manifest_defaults = run_manifest::manifest_defaults_layer(layer); + Ok(ResolvedAppStateSettings { + server_settings: ServerSettingsBuilder::from_layer(layer)?, + manifest_run_settings: resolve_manifest_run_settings(&manifest_defaults), + manifest_defaults, + }) +} + fn system_sandbox_provider( manifest_run_settings: &std::result::Result, ) -> String { @@ -2500,7 +2526,10 @@ pub(crate) fn create_test_app_state_with_session_key( let settings = Arc::new(RwLock::new(settings)); ensure_test_auth_methods(&settings); build_app_state(AppStateConfig { - settings, + resolved_settings: { + let settings = settings.read().expect("settings lock poisoned"); + resolve_app_state_settings(&settings).expect("test settings should resolve") + }, registry_factory_override: None, max_concurrent_runs: 5, store, @@ -2535,7 +2564,10 @@ fn default_test_app_state_config( let vault_path = test_secret_store_path(); let server_env_path = vault_path.with_file_name("server.env"); AppStateConfig { - settings, + resolved_settings: { + let settings = settings.read().expect("settings lock poisoned"); + resolve_app_state_settings(&settings).expect("test settings should resolve") + }, registry_factory_override: None, max_concurrent_runs, store, @@ -2604,7 +2636,7 @@ fn load_test_server_secrets(path: PathBuf, env: HashMap) -> Serv pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result> { let AppStateConfig { - settings, + resolved_settings, registry_factory_override, max_concurrent_runs, store, @@ -2621,15 +2653,9 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Date: Thu, 23 Apr 2026 17:16:54 -0400 Subject: [PATCH 36/60] move serve storage overrides behind dense server settings --- lib/crates/fabro-server/src/serve.rs | 174 +++++++++++++-------------- 1 file changed, 85 insertions(+), 89 deletions(-) diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 902d4d667..62d5fd1b0 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -11,9 +11,7 @@ use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_sandbox::SandboxProvider; use fabro_types::ServerSettings; -use fabro_types::settings::server::{ - GithubIntegrationStrategy, ServerLayer, ServerStorageLayer, WebhookStrategy, -}; +use fabro_types::settings::server::{GithubIntegrationStrategy, WebhookStrategy}; use fabro_types::settings::{ GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, ServerNamespace, SettingsLayer, @@ -188,23 +186,6 @@ fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLaye settings } -fn apply_runtime_settings( - base: &SettingsLayer, - args: &ServeArgs, - data_dir: &Path, -) -> SettingsLayer { - apply_storage_dir_override(apply_serve_overrides(base, args), data_dir) -} - -fn apply_storage_dir_override(mut settings: SettingsLayer, data_dir: &Path) -> SettingsLayer { - let server = settings.server.get_or_insert_with(ServerLayer::default); - let storage = server - .storage - .get_or_insert_with(ServerStorageLayer::default); - storage.root = Some(InterpString::parse(&data_dir.display().to_string())); - settings -} - async fn resolve_github_webhook_ip_allowlist( resolved_server_settings: &ServerNamespace, github_meta_resolver: &GitHubMetaResolver, @@ -479,27 +460,15 @@ where } } -fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result { - ServerSettingsBuilder::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) -} - -pub fn resolve_bind_request_from_settings( - settings: &SettingsLayer, - explicit_bind: Option<&str>, -) -> anyhow::Result { - let resolved = ServerSettingsBuilder::from_layer(settings).map_err(anyhow::Error::from)?; - resolve_bind_request_from_server_settings(&resolved, explicit_bind) + let effective_settings = apply_serve_overrides(&disk_settings, args); + let mut resolved = resolve_app_state_settings(&effective_settings)?; + resolved.server_settings = resolved.server_settings.with_storage_override(data_dir); + Ok(resolved.server_settings.server) } pub fn resolve_bind_request_from_server_settings( @@ -609,7 +578,7 @@ where 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_server_settings = ServerSettingsBuilder::from_layer(&disk_settings)?.server; let data_dir = match storage_dir_override { Some(path) => path, None => resolve_interp_path(&disk_server_settings.storage.root)?, @@ -618,8 +587,11 @@ where 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_app_settings = resolve_app_state_settings(&effective_settings)?; + let effective_settings = apply_serve_overrides(&disk_settings, &args); + let mut resolved_app_settings = resolve_app_state_settings(&effective_settings)?; + resolved_app_settings.server_settings = resolved_app_settings + .server_settings + .with_storage_override(&data_dir); let resolved_server_settings = resolved_app_settings.server_settings.server.clone(); let (auth_mode, server_secrets) = resolve_startup( &server_env_path, @@ -745,11 +717,7 @@ where interval.tick().await; match load_settings_config(config_path_for_poll.as_deref()) { Ok(new_disk_settings) => { - let effective = apply_runtime_settings( - &new_disk_settings, - &args_for_poll, - &data_dir_for_poll, - ); + let effective = apply_serve_overrides(&new_disk_settings, &args_for_poll); let changed = { let cfg = shared_settings_for_poll .read() @@ -757,7 +725,14 @@ where *cfg != effective }; if changed { - match resolve_app_state_settings(&effective) + let resolved = + resolve_app_state_settings(&effective).map(|mut resolved| { + resolved.server_settings = resolved + .server_settings + .with_storage_override(&data_dir_for_poll); + resolved + }); + match resolved .and_then(|resolved| state_for_poll.replace_runtime_settings(resolved)) { Ok(()) => { @@ -1052,21 +1027,20 @@ mod tests { use std::time::Duration; use fabro_config::bind::{Bind, BindRequest}; - use fabro_config::parse_settings_layer; + use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::settings::SettingsLayer; use fabro_types::settings::interp::InterpString; use fabro_types::settings::server::ObjectStoreSettings; use fabro_util::Home; use super::{ - GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings, + GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_serve_overrides, 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, + resolve_bind_request_from_server_settings, resolve_github_webhook_ip_allowlist, + resolve_startup_github_webhook_ip_allowlist, server_bind_title, server_title, }; - use crate::server::create_app_state_with_options; + use crate::server::resolve_app_state_settings; fn parse_settings(source: &str) -> SettingsLayer { let mut layer = parse_settings_layer(source).expect("v2 fixture should parse"); @@ -1074,9 +1048,15 @@ mod tests { layer } + fn resolved_server_settings(layer: &SettingsLayer) -> fabro_types::settings::ServerNamespace { + ServerSettingsBuilder::from_layer(layer) + .expect("settings should resolve") + .server + } + #[test] - fn apply_runtime_settings_preserves_storage_dir() { - let base = SettingsLayer::default(); + fn runtime_server_settings_preserve_storage_dir_override() { + let base = parse_settings("_version = 1\n"); let args = ServeArgs { bind: None, model: None, @@ -1090,19 +1070,20 @@ mod tests { watch_web: false, }; - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro-storage")); + let mut resolved = + resolve_app_state_settings(&apply_serve_overrides(&base, &args)).expect("settings"); + resolved.server_settings = resolved + .server_settings + .with_storage_override(&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!( + resolved.server_settings.server.storage.root.as_source(), + "/srv/fabro-storage" + ); } #[test] - fn app_state_server_settings_use_effective_runtime_layer_storage_override() { + fn runtime_server_settings_keep_disk_defaults_out_of_manifest_defaults() { let base = parse_settings( r#" _version = 1 @@ -1124,16 +1105,19 @@ root = "/srv/from-disk" watch_web: false, }; - let effective = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/from-runtime")); - let state = create_app_state_with_options(effective, 5); + let mut resolved = + resolve_app_state_settings(&apply_serve_overrides(&base, &args)).expect("settings"); + 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_defaults.server, None, + "manifest defaults should stay free of server-only overrides" ); } @@ -1160,7 +1144,7 @@ enabled = false watch_web: false, }; - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro")); + let resolved = apply_serve_overrides(&base, &args); assert_eq!( resolved @@ -1188,7 +1172,7 @@ enabled = false watch_web: false, }; - let resolved = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/fabro")); + let resolved = apply_serve_overrides(&base, &args); assert_eq!( resolved @@ -1201,15 +1185,20 @@ enabled = false } #[test] - fn resolve_bind_request_from_settings_defaults_to_socket_when_listen_is_absent() { - let bind = - resolve_bind_request_from_settings(&SettingsLayer::test_default(), None).expect("bind"); + fn resolve_bind_request_from_server_settings_defaults_to_socket_when_listen_is_absent() { + let bind = resolve_bind_request_from_server_settings( + &ServerSettingsBuilder::from_layer(&SettingsLayer::test_default()) + .expect("settings should resolve"), + 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() { + fn resolve_bind_request_from_server_settings_uses_configured_tcp_when_no_explicit_bind_is_given() + { let settings = parse_settings( r#" _version = 1 @@ -1220,13 +1209,17 @@ 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( + &ServerSettingsBuilder::from_layer(&settings).expect("settings should resolve"), + 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() { + fn resolve_bind_request_from_server_settings_prefers_explicit_bind_over_config() { let settings = parse_settings( r#" _version = 1 @@ -1237,17 +1230,22 @@ 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( + &ServerSettingsBuilder::from_layer(&settings).expect("settings should resolve"), + 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 = + ServerSettingsBuilder::from_layer(&SettingsLayer::test_default()).expect("settings"); - 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())); } @@ -1266,7 +1264,7 @@ strategy = "token" "#, ); - let resolved = resolve_server_settings(&base).expect("settings should resolve"); + let resolved = resolved_server_settings(&base); assert!(resolved.web.enabled); } @@ -1330,7 +1328,7 @@ root = "{}" root.display() )); - let resolved = resolve_server_settings(&settings).expect("settings should resolve"); + let resolved = resolved_server_settings(&settings); let (_object_store, prefix, flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1351,7 +1349,7 @@ disk_cache = true ", ); - let resolved = resolve_server_settings(&settings).expect("settings should resolve"); + let resolved = resolved_server_settings(&settings); let (_object_store, _prefix, _flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1475,7 +1473,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 = resolved_server_settings(&parse_settings( r#" _version = 1 @@ -1490,8 +1488,7 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )) - .expect("settings should resolve"); + )); let cache_dir = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1512,7 +1509,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 = resolved_server_settings(&parse_settings( r#" _version = 1 @@ -1527,8 +1524,7 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )) - .expect("settings should resolve"); + )); let cache_dir = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); From 2ec9e8bcdc05353c0989d0b61997758368c7b2ea Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:22:47 -0400 Subject: [PATCH 37/60] route project config discovery through file-based builders --- lib/crates/fabro-cli/src/commands/graph.rs | 6 +-- lib/crates/fabro-cli/src/commands/parse.rs | 2 +- .../fabro-cli/src/commands/preflight.rs | 3 +- .../fabro-cli/src/commands/run/create.rs | 3 +- lib/crates/fabro-cli/src/commands/validate.rs | 6 +-- .../fabro-cli/src/commands/workflow/create.rs | 4 +- .../fabro-cli/src/commands/workflow/list.rs | 4 +- lib/crates/fabro-cli/src/manifest_builder.rs | 34 ++++++------ lib/crates/fabro-config/src/builders.rs | 14 ++++- lib/crates/fabro-config/src/project.rs | 53 ++++++++----------- 10 files changed, 64 insertions(+), 65 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index cd8f9ee74..a8208bfe7 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -11,8 +11,7 @@ use std::io::Write; use anyhow::{Context, bail}; use fabro_api::types; -use fabro_config::user::{active_settings_path, load_settings_config}; -use fabro_types::settings::SettingsLayer; +use fabro_config::user::active_settings_path; use fabro_util::terminal::Styles; use tracing::debug; @@ -36,10 +35,9 @@ 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(), + args_layer: Default::default(), args: None, run_id: None, - user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; 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 9cebf4f8c..0b6bdcbfe 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,5 +1,5 @@ use anyhow::bail; -use fabro_config::user::{active_settings_path, load_settings_config}; +use fabro_config::user::active_settings_path; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -26,7 +26,6 @@ pub(crate) async fn execute( args_layer: preflight_args_layer(&args)?, args: preflight_manifest_args(&args), run_id: None, - user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index a2de07329..775ada9de 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,4 +1,4 @@ -use fabro_config::user::{active_settings_path, load_settings_config}; +use fabro_config::user::active_settings_path; use fabro_types::RunId; use fabro_util::terminal::Styles; @@ -41,7 +41,6 @@ pub(crate) async fn create_run( args_layer: cli_args_config, args: run_manifest_args(args), run_id, - user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index f174b1b73..19fcbc96a 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,6 +1,5 @@ use anyhow::bail; -use fabro_config::user::{active_settings_path, load_settings_config}; -use fabro_types::settings::SettingsLayer; +use fabro_config::user::active_settings_path; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -19,10 +18,9 @@ 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(), + args_layer: Default::default(), args: None, run_id: None, - user_layer: load_settings_config(None)?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().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/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 3879d98bd..4f4d3ee59 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -28,9 +28,6 @@ pub(crate) struct ManifestBuildInput { pub args_layer: SettingsLayer, pub args: Option, pub run_id: Option, - /// User-level settings layer. Production callers load via - /// `load_settings_config(None)`; 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, @@ -57,7 +54,7 @@ struct WorkflowScanInput { pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; - if root_resolution.workflow_config.is_none() + if root_resolution.workflow_toml_path.is_none() && !root_resolution.resolved_workflow_path.is_file() { return Err(fabro_config::Error::WorkflowNotFound( @@ -70,16 +67,22 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result); @@ -186,18 +186,30 @@ impl WorkflowSettingsBuilder { self } + pub fn workflow_file(self, path: &Path) -> Result { + Ok(self.workflow_layer(run::load_run_config(path)?)) + } + #[must_use] pub fn project_layer(mut self, layer: SettingsLayer) -> Self { self.project = layer; self } + pub fn project_file(self, path: &Path) -> Result { + Ok(self.project_layer(load_settings_path(path)?)) + } + #[must_use] pub fn user_layer(mut self, layer: SettingsLayer) -> Self { self.user = layer; self } + pub fn user_file(self, path: &Path) -> Result { + Ok(self.user_layer(load_settings_path(path)?)) + } + #[must_use] pub fn server_layer(mut self, layer: SettingsLayer) -> Self { self.server = layer; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 4736db113..61239402c 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -23,7 +23,6 @@ const CONFIG_FILENAME: &str = ".fabro/project.toml"; 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, } @@ -32,7 +31,7 @@ pub struct WorkflowPathResolution { /// /// 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 = WorkflowSettingsBuilder::project_from_layer(&config) .map_err(|errors| Error::resolve("Failed to resolve project settings", errors.into()))? @@ -42,14 +41,13 @@ pub fn load_project_config(path: &Path) -> Result { } /// 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) @@ -93,7 +91,6 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result Result { - 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) @@ -339,10 +335,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. @@ -350,11 +346,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, } @@ -383,11 +383,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 = WorkflowSettingsBuilder::project_from_layer(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)) @@ -488,9 +489,8 @@ 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] @@ -529,9 +529,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] @@ -550,12 +548,7 @@ 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] From a5978b0b3c3f6df6f15c0db7853639fbe5690d25 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:28:45 -0400 Subject: [PATCH 38/60] split cli manifest overrides into run and cli layers --- lib/crates/fabro-cli/src/commands/graph.rs | 3 +- .../fabro-cli/src/commands/preflight.rs | 6 ++- .../fabro-cli/src/commands/run/create.rs | 7 +-- .../fabro-cli/src/commands/run/overrides.rs | 18 +++++--- lib/crates/fabro-cli/src/commands/validate.rs | 3 +- lib/crates/fabro-cli/src/manifest_builder.rs | 43 ++++++++++++------- lib/crates/fabro-config/src/run.rs | 13 +++++- 7 files changed, 63 insertions(+), 30 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index a8208bfe7..4cfab7f13 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -35,7 +35,8 @@ pub(crate) async fn run( let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), - args_layer: Default::default(), + run_overrides: None, + cli_overrides: None, args: None, run_id: None, user_settings_path: Some(active_settings_path(None)), diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 0b6bdcbfe..17b38026d 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -7,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; @@ -19,11 +19,13 @@ 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_settings_path: Some(active_settings_path(None)), diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 775ada9de..50972569f 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -3,7 +3,7 @@ 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}; @@ -26,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 @@ -38,7 +38,8 @@ 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_settings_path: Some(active_settings_path(None)), diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 7238c05e0..afdbac0d3 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -3,16 +3,22 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use fabro_sandbox::SandboxProvider; +use fabro_types::settings::ReplaceMap; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; -use fabro_types::settings::{ReplaceMap, SettingsLayer}; 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, + pub cli_overrides: Option, pub args: Option, pub run_id: Option, /// Path to the user settings file (for inclusion in @@ -67,8 +70,13 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result, settings: &WorkflowSettings, root_source: &str, root_dot_path: &Path, @@ -414,10 +422,12 @@ fn resolve_manifest_goal( // 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` @@ -624,7 +634,8 @@ 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_settings_path: None, @@ -703,7 +714,8 @@ 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_settings_path: None, @@ -755,7 +767,8 @@ 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_settings_path: None, diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index e27b18c79..5c6bff1dc 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use fabro_types::settings::run::{ - ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunGoalLayer, RunNamespace, + ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunGoalLayer, RunLayer, RunNamespace, }; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -81,6 +81,17 @@ pub fn resolve_run_goal( resolve_layer_goal(goal, base_dir).map(Some) } +pub fn resolve_run_goal_from_layer( + run: &RunLayer, + base_dir: &Path, +) -> std::result::Result, ResolveRunGoalError> { + 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, From 2c55f10e6238f83220a7084547f2454454f8ec3c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:33:31 -0400 Subject: [PATCH 39/60] store manifest defaults as run layers --- lib/crates/fabro-config/src/builders.rs | 15 +++++ lib/crates/fabro-server/src/run_manifest.rs | 22 +++---- lib/crates/fabro-server/src/serve.rs | 4 +- lib/crates/fabro-server/src/server.rs | 70 ++++++++++----------- 4 files changed, 61 insertions(+), 50 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 3f8d001b3..eefa527d3 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -150,6 +150,13 @@ impl RunSettingsBuilder { 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, Debug, Default)] @@ -216,6 +223,14 @@ impl WorkflowSettingsBuilder { 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 { diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 7222efdc5..b22037daa 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -47,16 +47,12 @@ pub(crate) struct PreparedManifest { pub working_directory: PathBuf, } -pub(crate) fn manifest_defaults_layer(settings: &SettingsLayer) -> SettingsLayer { - SettingsLayer { - version: settings.version, - run: settings.run.clone(), - ..SettingsLayer::default() - } +pub(crate) fn manifest_run_defaults(settings: &SettingsLayer) -> RunLayer { + settings.run.clone().unwrap_or_default() } pub(crate) fn prepare_manifest( - manifest_defaults: &SettingsLayer, + manifest_run_defaults: &RunLayer, manifest: &types::RunManifest, ) -> Result { if manifest.version != 1 { @@ -93,7 +89,7 @@ pub(crate) fn prepare_manifest( .workflow_layer(workflow_layer) .project_layer(project_layer) .user_layer(user_layer) - .server_layer(manifest_defaults.clone()) + .server_run_defaults(manifest_run_defaults.clone()) .build_layer(); if let Some(goal) = manifest.goal.as_ref() { let run = settings_layer.run.get_or_insert_with(RunLayer::default); @@ -975,7 +971,7 @@ mod tests { #[test] fn prepare_manifest_preserves_explicit_manifest_dry_run() { - let server_settings = manifest_defaults_layer(&server_settings_fixture( + let server_settings = manifest_run_defaults(&server_settings_fixture( r#" _version = 1 @@ -1009,7 +1005,7 @@ root = "/srv/fabro" #[test] fn prepare_manifest_prefers_bundled_settings_without_duplication() { - let server_settings = manifest_defaults_layer(&server_settings_fixture( + let server_settings = manifest_run_defaults(&server_settings_fixture( r#" _version = 1 @@ -1071,7 +1067,7 @@ app_id = "snapshotted-app-id" async fn invalid_preflight_returns_diagnostics_without_runtime_checks() { let state = crate::server::create_app_state(); let prepared = prepare_manifest( - &manifest_defaults_layer(&default_settings_fixture()), + &manifest_run_defaults(&default_settings_fixture()), &invalid_manifest(), ) .unwrap(); @@ -1110,7 +1106,7 @@ enabled = true }); let prepared = prepare_manifest( - &manifest_defaults_layer(&default_settings_fixture()), + &manifest_run_defaults(&default_settings_fixture()), &manifest, ) .unwrap(); @@ -1151,7 +1147,7 @@ provider = "daytona" }); let prepared = prepare_manifest( - &manifest_defaults_layer(&default_settings_fixture()), + &manifest_run_defaults(&default_settings_fixture()), &manifest, ) .unwrap(); diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 62d5fd1b0..8a38d57ef 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -1030,6 +1030,7 @@ mod tests { use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; use fabro_types::settings::SettingsLayer; use fabro_types::settings::interp::InterpString; + use fabro_types::settings::run::RunLayer; use fabro_types::settings::server::ObjectStoreSettings; use fabro_util::Home; @@ -1116,7 +1117,8 @@ root = "/srv/from-disk" "/srv/from-runtime" ); assert_eq!( - resolved.manifest_defaults.server, None, + resolved.manifest_run_defaults, + RunLayer::default(), "manifest defaults should stay free of server-only overrides" ); } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 8ebdeb820..bc700ba2a 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -64,7 +64,7 @@ use fabro_store::{ }; #[cfg(test)] use fabro_types::BlockedReason; -use fabro_types::settings::run::RunMode; +use fabro_types::settings::run::{RunLayer, RunMode}; use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, ServerLayer, @@ -575,7 +575,7 @@ pub struct AppState { pub(crate) vault: Arc>, pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, - manifest_defaults: RwLock>, + manifest_run_defaults: RwLock>, manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, @@ -601,7 +601,7 @@ pub(crate) struct AppStateConfig { #[derive(Clone)] pub(crate) struct ResolvedAppStateSettings { pub(crate) server_settings: ServerSettings, - pub(crate) manifest_defaults: SettingsLayer, + pub(crate) manifest_run_defaults: RunLayer, pub(crate) manifest_run_settings: std::result::Result, } @@ -649,12 +649,12 @@ fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelU } impl AppState { - pub(crate) fn manifest_defaults(&self) -> Arc { + pub(crate) fn manifest_run_defaults(&self) -> Arc { Arc::clone( &self - .manifest_defaults + .manifest_run_defaults .read() - .expect("manifest defaults lock poisoned"), + .expect("manifest run defaults lock poisoned"), ) } @@ -812,18 +812,18 @@ impl AppState { ) -> anyhow::Result<()> { let ResolvedAppStateSettings { server_settings, - manifest_defaults, + manifest_run_defaults, manifest_run_settings, } = resolved_settings; let server_settings = Arc::new(server_settings); - let manifest_defaults = Arc::new(manifest_defaults); + let manifest_run_defaults = Arc::new(manifest_run_defaults); resolve_canonical_origin(&server_settings.server, &self.env_lookup) .map_err(anyhow::Error::msg)?; *self - .manifest_defaults + .manifest_run_defaults .write() - .expect("manifest defaults lock poisoned") = manifest_defaults; + .expect("manifest run defaults lock poisoned") = manifest_run_defaults; *self .manifest_run_settings .write() @@ -1701,19 +1701,19 @@ fn build_prune_plan( } fn resolve_manifest_run_settings( - manifest_defaults: &SettingsLayer, + manifest_run_defaults: &RunLayer, ) -> std::result::Result { - RunSettingsBuilder::from_layer(manifest_defaults).map_err(|err| err.to_string()) + RunSettingsBuilder::from_run_layer(manifest_run_defaults).map_err(|err| err.to_string()) } pub(crate) fn resolve_app_state_settings( layer: &SettingsLayer, ) -> anyhow::Result { - let manifest_defaults = run_manifest::manifest_defaults_layer(layer); + let manifest_run_defaults = run_manifest::manifest_run_defaults(layer); Ok(ResolvedAppStateSettings { server_settings: ServerSettingsBuilder::from_layer(layer)?, - manifest_run_settings: resolve_manifest_run_settings(&manifest_defaults), - manifest_defaults, + manifest_run_settings: resolve_manifest_run_settings(&manifest_run_defaults), + manifest_run_defaults, }) } @@ -2654,7 +2654,7 @@ 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 manifest_defaults = state.manifest_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &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(), }; @@ -4206,8 +4206,8 @@ async fn run_preflight( State(state): State>, Json(req): Json, ) -> Response { - let manifest_defaults = state.manifest_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &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(), }; @@ -4233,11 +4233,12 @@ async fn render_graph_from_manifest( State(state): State>, Json(req): Json, ) -> Response { - let manifest_defaults = state.manifest_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_defaults.as_ref(), &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(), @@ -7578,17 +7579,14 @@ root = "/srv/new" .mode, RunMode::DryRun ); - let manifest_defaults = state.manifest_defaults(); - assert_eq!(manifest_defaults.version, Some(1)); + let manifest_run_defaults = state.manifest_run_defaults(); assert_eq!( - manifest_defaults - .run + manifest_run_defaults + .execution .as_ref() - .and_then(|run| run.execution.as_ref()) .and_then(|execution| execution.mode), Some(RunMode::DryRun) ); - assert!(manifest_defaults.server.is_none()); } #[test] @@ -7658,7 +7656,7 @@ retros = false let server_settings = ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_defaults_layer(&settings)); + resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); let features = system_features(&server_settings, &manifest_run_settings); assert_eq!(features.session_sandboxes, Some(true)); @@ -7685,7 +7683,7 @@ provider = "invalid-provider" let server_settings = ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_defaults_layer(&settings)); + resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); let features = system_features(&server_settings, &manifest_run_settings); assert_eq!(features.session_sandboxes, Some(true)); @@ -7704,7 +7702,7 @@ provider = "daytona" ) .expect("settings fixture should parse"); let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_defaults_layer(&settings)); + resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); assert_eq!(system_sandbox_provider(&manifest_run_settings), "daytona"); } @@ -7721,7 +7719,7 @@ provider = "invalid-provider" ) .expect("settings fixture should parse"); let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_defaults_layer(&settings)); + resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); assert_eq!( system_sandbox_provider(&manifest_run_settings), From 7d2600126ae6569c2b9e35647bafdab70984125d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:37:22 -0400 Subject: [PATCH 40/60] drop raw settings merges from cli loaders --- lib/crates/fabro-cli/src/local_server.rs | 23 ++-- lib/crates/fabro-cli/src/user_config.rs | 148 +++++++++++++++-------- lib/crates/fabro-config/src/builders.rs | 29 +++++ 3 files changed, 137 insertions(+), 63 deletions(-) diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 17f4599dd..14988105c 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_config::bind::BindRequest; use fabro_types::ServerSettings; -use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; +use fabro_types::settings::{InterpString, ServerAuthMethod}; use crate::user_config; @@ -70,17 +70,12 @@ fn storage_dir_from_toml_with_lookup( source: &str, lookup: &dyn Fn(&str) -> Option, ) -> Result { - let layer: SettingsLayer = toml::from_str(source) + let document: toml::Value = toml::from_str(source) .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; - let storage_root = layer - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.clone()) + let storage_root = string_at_path(&document, &["server", "storage", "root"]) + .map(|root| InterpString::parse(&root)) .unwrap_or_else(|| { - fabro_types::settings::InterpString::parse( - &fabro_config::user::default_storage_dir().to_string_lossy(), - ) + InterpString::parse(&fabro_config::user::default_storage_dir().to_string_lossy()) }); let resolved_root = storage_root .resolve(lookup) @@ -88,6 +83,14 @@ fn storage_dir_from_toml_with_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) +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index ea7b304de..998def874 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -3,11 +3,13 @@ use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; +use fabro_config::user::default_socket_path; pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; -use fabro_config::user::{default_socket_path, load_settings_config}; -use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder}; +use fabro_config::{ + RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, load_config_file, +}; use fabro_types::settings::cli::{CliLayer, CliTargetSettings}; -use fabro_types::settings::{CliNamespace, Combine, RunNamespace, SettingsLayer}; +use fabro_types::settings::{CliNamespace, InterpString, RunNamespace}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::version::FABRO_VERSION; use tracing::debug; @@ -27,39 +29,18 @@ pub(crate) fn load_resolved_settings( storage_dir: Option<&Path>, cli_layer: Option<&CliLayer>, ) -> anyhow::Result { - let layer = load_settings_config(config_path)?; - resolve_loaded_settings(layer, storage_dir, cli_layer) -} - -fn resolve_loaded_settings( - layer: SettingsLayer, - storage_dir: Option<&Path>, - 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_layer(&layer, storage_dir)?; - let config_log_level = layer - .server - .as_ref() - .and_then(|server| server.logging.as_ref()) - .and_then(|logging| logging.level.clone()); - let run_settings = RunSettingsBuilder::from_layer(&layer).map_err(|err| err.to_string()); - let server_settings = ServerSettingsBuilder::from_layer(&layer) + 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_layer = if let Some(cli_layer) = cli_layer { - SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - } - .combine(layer.clone()) - } else { - layer.clone() - }; - let user_settings = UserSettingsBuilder::from_layer(&user_settings_layer)?; + let user_settings = load_user_settings(config_path, cli_layer)?; Ok(LoadedSettings { storage_dir, @@ -70,15 +51,52 @@ fn resolve_loaded_settings( }) } -fn storage_dir_from_layer( - layer: &SettingsLayer, - storage_dir: Option<&Path>, -) -> anyhow::Result { - storage_dir_from_layer_with_lookup(layer, storage_dir, &|name| std::env::var(name).ok()) +fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result { + let table: toml::Table = load_config_file(config_path, "settings.toml")?; + Ok(toml::Value::Table(table)) } -fn storage_dir_from_layer_with_lookup( - layer: &SettingsLayer, +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 { @@ -86,18 +104,21 @@ fn storage_dir_from_layer_with_lookup( return Ok(dir.to_path_buf()); } - let storage_root = layer - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.clone()) - .unwrap_or_else(|| { - fabro_types::settings::InterpString::parse(&default_storage_dir().to_string_lossy()) - }); + let storage_root = string_at_path(document, &["server", "storage", "root"]) + .map(|root| InterpString::parse(&root)) + .unwrap_or_else(|| InterpString::parse(&default_storage_dir().to_string_lossy())); 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]`. /// Returns either an http(s) URL or a unix socket path. fn cli_target_from_settings(settings: &CliNamespace) -> Option { @@ -157,9 +178,30 @@ pub(crate) fn load_resolved_settings_from_toml( storage_dir: Option<&Path>, cli_layer: Option<&CliLayer>, ) -> anyhow::Result { - let layer: SettingsLayer = toml::from_str(source) + let document: toml::Value = toml::from_str(source) .map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?; - resolve_loaded_settings(layer, storage_dir, cli_layer) + 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)] @@ -289,17 +331,17 @@ url = "https://config.example.com" #[test] fn storage_dir_defaults_without_server_auth_methods() { - let layer = SettingsLayer::default(); + let document = toml::Value::Table(toml::Table::new()); assert_eq!( - storage_dir_from_layer(&layer, None).unwrap(), + storage_dir_from_document(&document, None).unwrap(), default_storage_dir() ); } #[test] fn storage_dir_uses_explicit_server_storage_root() { - let layer: SettingsLayer = toml::from_str( + let document: toml::Value = toml::from_str( r#" _version = 1 @@ -310,14 +352,14 @@ root = "/srv/fabro" .expect("fixture should parse"); assert_eq!( - storage_dir_from_layer(&layer, None).unwrap(), + storage_dir_from_document(&document, None).unwrap(), PathBuf::from("/srv/fabro") ); } #[test] fn storage_dir_resolves_env_interpolated_root() { - let layer: SettingsLayer = toml::from_str( + let document: toml::Value = toml::from_str( r#" _version = 1 @@ -329,7 +371,7 @@ root = "{{ env.FABRO_STORAGE_ROOT }}" let temp = tempfile::tempdir().unwrap(); assert_eq!( - storage_dir_from_layer_with_lookup(&layer, None, &|name| { + storage_dir_from_document_with_lookup(&document, None, &|name| { (name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string()) }) .unwrap(), diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index eefa527d3..84bd32ac3 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -101,17 +101,33 @@ impl UserSettingsBuilder { 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 = parse_settings_layer(source) .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 = parse_settings_layer(source) + .map_err(|err| Error::parse("Failed to parse settings file", err))?; + Self::from_layer_with_cli_overrides(&layer, cli) + } + pub fn from_layer(layer: &SettingsLayer) -> Result { let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); let mut errors = Vec::new(); @@ -123,6 +139,19 @@ impl UserSettingsBuilder { errors, ) } + + pub 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; From 12ca64f5bfd252f3f88980992feace8b3aec640d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:40:24 -0400 Subject: [PATCH 41/60] route manifest assembly through builder source setters --- lib/crates/fabro-config/src/builders.rs | 26 ++++++ lib/crates/fabro-server/src/run_manifest.rs | 87 +++++++++++---------- 2 files changed, 71 insertions(+), 42 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 84bd32ac3..a76d20415 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -222,6 +222,20 @@ impl WorkflowSettingsBuilder { 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 = parse_settings_layer(source) + .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)?)) } @@ -232,6 +246,12 @@ impl WorkflowSettingsBuilder { self } + pub fn project_toml(self, source: &str) -> Result { + let layer = parse_settings_layer(source) + .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)?)) } @@ -242,6 +262,12 @@ impl WorkflowSettingsBuilder { self } + pub fn user_toml(self, source: &str) -> Result { + let layer = parse_settings_layer(source) + .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)?)) } diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index b22037daa..636b48a95 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -18,10 +18,9 @@ use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ ApprovalMode, DaytonaDockerfileLayer, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource, - RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunNamespace, - RunSandboxLayer, + RunExecutionLayer, RunGoal, RunLayer, RunMode, RunModelLayer, RunNamespace, RunSandboxLayer, }; -use fabro_types::settings::{Combine, ReplaceMap, ServerNamespace, SettingsLayer}; +use fabro_types::settings::{ReplaceMap, ServerNamespace, SettingsLayer}; use fabro_types::{RunId, WorkflowSettings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; @@ -47,6 +46,12 @@ pub(crate) struct PreparedManifest { pub working_directory: PathBuf, } +#[derive(Clone, Debug, Default)] +struct ManifestSettingsOverrides { + run: Option, + cli: Option, +} + pub(crate) fn manifest_run_defaults(settings: &SettingsLayer) -> RunLayer { settings.run.clone().unwrap_or_default() } @@ -68,35 +73,44 @@ 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_layer = WorkflowSettingsBuilder::new() - .args_layer(args_layer) - .workflow_layer(workflow_layer) - .project_layer(project_layer) - .user_layer(user_layer) - .server_run_defaults(manifest_run_defaults.clone()) - .build_layer(); - if let Some(goal) = manifest.goal.as_ref() { - let run = settings_layer.run.get_or_insert_with(RunLayer::default); - run.goal = Some(RunGoalLayer::Inline(InterpString::parse(&goal.text))); + { + if let Some(source) = config.source.as_deref() { + workflow_settings_builder = workflow_settings_builder.user_toml(source)?; + } } - let settings = WorkflowSettingsBuilder::from_layer(&settings_layer) + 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() { + settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text))); + } Ok(PreparedManifest { cwd: cwd.clone(), @@ -192,33 +206,26 @@ 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_settings_layer(&config.source) .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; - Ok(layer) + Ok(layer.run.unwrap_or_default()) } -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 { @@ -271,11 +278,7 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> SettingsLayer { }) }); - SettingsLayer { - run, - cli, - ..SettingsLayer::default() - } + ManifestSettingsOverrides { run, cli } } fn parse_labels(labels: &[String]) -> HashMap { From b9fe542c5b9a2d76f057d745ecd8ef1c5cf10254 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:45:09 -0400 Subject: [PATCH 42/60] move serve runtime resolution behind config helper --- lib/crates/fabro-config/src/builders.rs | 62 +++++++++++- lib/crates/fabro-config/src/lib.rs | 4 +- lib/crates/fabro-server/src/serve.rs | 122 +++++++++++++++--------- 3 files changed, 141 insertions(+), 47 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index a76d20415..0decb9779 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -2,7 +2,8 @@ use std::fmt; use std::path::Path; use fabro_types::settings::{ - CliLayer, Combine, ProjectNamespace, RunLayer, RunNamespace, SettingsLayer, WorkflowNamespace, + CliLayer, Combine, ProjectNamespace, RunLayer, RunNamespace, ServerLayer, SettingsLayer, + WorkflowNamespace, }; use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; @@ -188,6 +189,65 @@ impl RunSettingsBuilder { } } +#[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 = parse_settings_layer(source) + .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, diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index f438e7990..7464751ee 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -25,8 +25,8 @@ pub mod user; use std::path::Path; pub use builders::{ - ResolveErrors, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, - WorkflowSettingsBuilder, + ResolveErrors, RunSettingsBuilder, ServerRuntimeSettings, ServerSettingsBuilder, + UserSettingsBuilder, WorkflowSettingsBuilder, load_server_runtime_settings, }; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 8a38d57ef..0bee1bccb 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -6,15 +6,17 @@ use std::time::Duration; use anyhow::Context; use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; -use fabro_config::user::load_settings_config; -use fabro_config::{ServerSettingsBuilder, Storage}; +use fabro_config::{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::ServerSettings; -use fabro_types::settings::server::{GithubIntegrationStrategy, WebhookStrategy}; +#[cfg(test)] +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::run::{RunLayer, RunModelLayer, RunSandboxLayer}; +use fabro_types::settings::server::{GithubIntegrationStrategy, ServerWebLayer, WebhookStrategy}; use fabro_types::settings::{ - GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, - ServerNamespace, SettingsLayer, + GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerLayer, + ServerListenSettings, ServerNamespace, }; use fabro_util::terminal::Styles; use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey}; @@ -31,8 +33,8 @@ 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, resolve_app_state_settings, shutdown_active_workers, + 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}; @@ -158,31 +160,42 @@ 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()); } + ( + (run != RunLayer::default()).then_some(run), + (server != ServerLayer::default()).then_some(server), + ) +} + +#[cfg(test)] +fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLayer { + let (run, server) = serve_overrides(args); + let mut settings = base.clone(); + if let Some(run) = run { + settings.run = Some(run); + } + if let Some(server) = server { + settings.server = Some(server); + } settings } @@ -464,9 +477,9 @@ 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_serve_overrides(&disk_settings, args); - let mut resolved = resolve_app_state_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) } @@ -577,8 +590,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 = ServerSettingsBuilder::from_layer(&disk_settings)?.server; + 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)?, @@ -586,12 +605,14 @@ 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_serve_overrides(&disk_settings, &args); - let mut resolved_app_settings = resolve_app_state_settings(&effective_settings)?; - resolved_app_settings.server_settings = resolved_app_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, @@ -603,7 +624,7 @@ where &resolved_app_settings.server_settings, args.bind.as_deref(), )?; - let shared_settings = Arc::new(RwLock::new(effective_settings)); + 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; @@ -708,39 +729,52 @@ where 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_serve_overrides(&new_disk_settings, &args_for_poll); let changed = { let cfg = shared_settings_for_poll .read() .expect("config lock poisoned"); - *cfg != effective + *cfg != new_disk_settings }; if changed { - let resolved = - resolve_app_state_settings(&effective).map(|mut resolved| { - resolved.server_settings = resolved - .server_settings - .with_storage_override(&data_dir_for_poll); - resolved - }); - match resolved - .and_then(|resolved| state_for_poll.replace_runtime_settings(resolved)) - { - Ok(()) => { - *shared_settings_for_poll - .write() - .expect("config lock poisoned") = effective; - 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"); } From b5684ead9427b9df4630225fb43e88ec6bcb1faa Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 17:51:20 -0400 Subject: [PATCH 43/60] fix stale dense run fixtures in types and store tests --- lib/crates/fabro-checkpoint/src/metadata.rs | 5 +- lib/crates/fabro-store/src/run_state.rs | 8 +-- lib/crates/fabro-store/src/slate/mod.rs | 4 +- .../tests/serializable_projection.rs | 5 +- lib/crates/fabro-types/src/run_event/mod.rs | 7 +- .../fabro-types/tests/run_event_serde.rs | 67 +++--------------- .../fabro-types/tests/run_spec_methods.rs | 7 +- .../fabro-types/tests/run_spec_serde.rs | 68 +++---------------- 8 files changed, 34 insertions(+), 137 deletions(-) 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-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 7cbd25475..b4c3f39bf 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; @@ -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/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index cfea3dd4c..b34b83002 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -800,8 +800,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() { @@ -850,7 +849,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 { @@ -892,7 +891,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/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 }}"))) ); } From 9a898b12cda7393907b73129debb38522dec0b32 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:10:12 -0400 Subject: [PATCH 44/60] move sparse settings layers into fabro-config --- Cargo.lock | 1 + lib/crates/fabro-cli/src/args.rs | 5 +- lib/crates/fabro-cli/src/command_context.rs | 3 +- .../fabro-cli/src/commands/run/overrides.rs | 12 +- lib/crates/fabro-cli/src/manifest_builder.rs | 22 +- lib/crates/fabro-cli/src/user_config.rs | 4 +- lib/crates/fabro-config/Cargo.toml | 2 + lib/crates/fabro-config/src/builders.rs | 7 +- lib/crates/fabro-config/src/defaults.rs | 4 +- lib/crates/fabro-config/src/layers/cli.rs | 108 ++++ lib/crates/fabro-config/src/layers/combine.rs | 337 ++++++++++++ .../fabro-config/src/layers/features.rs | 14 + lib/crates/fabro-config/src/layers/maps.rs | 198 +++++++ lib/crates/fabro-config/src/layers/mod.rs | 38 ++ lib/crates/fabro-config/src/layers/project.rs | 21 + lib/crates/fabro-config/src/layers/run.rs | 493 ++++++++++++++++++ lib/crates/fabro-config/src/layers/server.rs | 260 +++++++++ .../fabro-config/src/layers/settings.rs | 133 +++++ .../fabro-config/src/layers/splice_array.rs | 261 ++++++++++ .../fabro-config/src/layers/workflow.rs | 20 + lib/crates/fabro-config/src/lib.rs | 18 + lib/crates/fabro-config/src/load.rs | 5 +- lib/crates/fabro-config/src/parse.rs | 2 +- lib/crates/fabro-config/src/project.rs | 4 +- lib/crates/fabro-config/src/resolve/cli.rs | 6 +- .../fabro-config/src/resolve/features.rs | 3 +- .../fabro-config/src/resolve/project.rs | 3 +- lib/crates/fabro-config/src/resolve/run.rs | 25 +- lib/crates/fabro-config/src/resolve/server.rs | 24 +- .../fabro-config/src/resolve/workflow.rs | 3 +- lib/crates/fabro-config/src/run.rs | 8 +- lib/crates/fabro-config/src/user.rs | 4 +- lib/crates/fabro-macros/src/lib.rs | 4 +- lib/crates/fabro-server/src/run_manifest.rs | 40 +- lib/crates/fabro-server/src/serve.rs | 12 +- lib/crates/fabro-server/src/server.rs | 28 +- lib/crates/fabro-types/src/settings/cli.rs | 12 +- lib/crates/fabro-types/src/settings/layer.rs | 2 +- .../fabro-types/src/settings/project.rs | 2 +- lib/crates/fabro-types/src/settings/run.rs | 24 +- lib/crates/fabro-types/src/settings/server.rs | 30 +- .../fabro-types/src/settings/workflow.rs | 2 +- 42 files changed, 2069 insertions(+), 135 deletions(-) create mode 100644 lib/crates/fabro-config/src/layers/cli.rs create mode 100644 lib/crates/fabro-config/src/layers/combine.rs create mode 100644 lib/crates/fabro-config/src/layers/features.rs create mode 100644 lib/crates/fabro-config/src/layers/maps.rs create mode 100644 lib/crates/fabro-config/src/layers/mod.rs create mode 100644 lib/crates/fabro-config/src/layers/project.rs create mode 100644 lib/crates/fabro-config/src/layers/run.rs create mode 100644 lib/crates/fabro-config/src/layers/server.rs create mode 100644 lib/crates/fabro-config/src/layers/settings.rs create mode 100644 lib/crates/fabro-config/src/layers/splice_array.rs create mode 100644 lib/crates/fabro-config/src/layers/workflow.rs diff --git a/Cargo.lock b/Cargo.lock index 8d1b03b93..e123d0e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1720,6 +1720,7 @@ dependencies = [ "chrono", "clap", "dirs", + "fabro-macros", "fabro-proc", "fabro-types", "fabro-util", diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 67ccdc5eb..f3e87a668 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 289ba63b7..6dbf54d6b 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,8 +2,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; +use fabro_config::CliLayer; use fabro_types::settings::RunNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index afdbac0d3..c1b73177b 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use fabro_sandbox::SandboxProvider; -use fabro_types::settings::ReplaceMap; -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_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}; diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index ed9a2c999..1f41459ef 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -10,14 +10,11 @@ use anyhow::{Context, Result, anyhow}; use fabro_api::types; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace}; -use fabro_config::{WorkflowSettingsBuilder, parse_settings_layer}; +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::settings::cli::CliLayer; -use fabro_types::settings::run::{ - DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal, RunLayer, -}; +use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal}; use fabro_types::{RunId, WorkflowSettings}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; @@ -349,12 +346,19 @@ fn collect_workflow_config_files( config: &types::ManifestWorkflowConfig, files: &mut HashMap, ) -> Result<()> { - let config_layer = parse_settings_layer(&config.source) + let mut document: toml::Table = config + .source + .parse() .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; - let dockerfile = config_layer - .run + let run = document + .remove("run") + .map(|value| 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()); diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 998def874..fa279d06e 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -6,9 +6,9 @@ pub(crate) use fabro_client::ServerTarget; use fabro_config::user::default_socket_path; pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; use fabro_config::{ - RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, load_config_file, + CliLayer, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, load_config_file, }; -use fabro_types::settings::cli::{CliLayer, CliTargetSettings}; +use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, InterpString, RunNamespace}; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::version::FABRO_VERSION; diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index a4ec98f7a..1a6b786b0 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -12,6 +12,7 @@ doctest = false [features] default = [] clap = ["dep:clap", "fabro-types/clap"] +test-support = [] [lints] workspace = true @@ -20,6 +21,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 index 0decb9779..3c6214fd6 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -1,10 +1,7 @@ use std::fmt; use std::path::Path; -use fabro_types::settings::{ - CliLayer, Combine, ProjectNamespace, RunLayer, RunNamespace, ServerLayer, SettingsLayer, - WorkflowNamespace, -}; +use fabro_types::settings::{ProjectNamespace, RunNamespace, WorkflowNamespace}; use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; use crate::defaults::DEFAULTS_LAYER; @@ -15,7 +12,7 @@ use crate::resolve::{ resolve_workflow, }; use crate::user::load_settings_config; -use crate::{Error, Result, run}; +use crate::{CliLayer, Combine, Error, Result, RunLayer, ServerLayer, SettingsLayer, run}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolveErrors(pub Vec); diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs index 457397e0d..d4684211a 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -1,8 +1,6 @@ use std::sync::LazyLock; -use fabro_types::settings::SettingsLayer; - -use crate::parse_settings_layer; +use crate::{SettingsLayer, parse_settings_layer}; pub(crate) static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { parse_settings_layer(include_str!("defaults.toml")) 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-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs new file mode 100644 index 000000000..df085549e --- /dev/null +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -0,0 +1,337 @@ +use std::collections::HashMap; + +use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; +use fabro_types::settings::run::{ + AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, WorktreeMode, +}; +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::run::{ + DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, + LocalSandboxLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, + RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, +}; +use super::server::{ + ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer, + ServerListenLayer, ServerLoggingLayer, +}; + +pub trait Combine { + /// Combine two values, preferring the values in `self`. + #[must_use] + fn combine(self, other: Self) -> Self; +} + +impl Combine for Option { + fn combine(self, other: Self) -> Self { + match (self, other) { + (Some(this), Some(fallback)) => Some(this.combine(fallback)), + (this, fallback) => this.or(fallback), + } + } +} + +macro_rules! impl_combine_or_option { + ($($ty:ty),+ $(,)?) => { + $( + impl Combine for Option<$ty> { + fn combine(self, other: Self) -> Self { + self.or(other) + } + } + )+ + }; +} + +impl_combine_or_option!( + String, + bool, + u16, + u32, + u64, + usize, + i32, + Duration, + InterpString, + Size, + CliAuthStrategy, + OutputFormat, + OutputVerbosity, + AgentPermissions, + ApprovalMode, + HookAgentMarker, + HookTlsMode, + MergeStrategy, + RunMode, + WorktreeMode, + GithubIntegrationStrategy, + ObjectStoreProvider, + ServerAuthMethod, + WebhookStrategy, +); + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +macro_rules! impl_combine_self { + ($($ty:ty),+ $(,)?) => { + $( + impl Combine for $ty { + fn combine(self, _other: Self) -> Self { + self + } + } + )+ + }; +} + +impl_combine_self!( + CliAuthLayer, + CliLoggingLayer, + CliTargetLayer, + FeaturesLayer, + DaytonaNetworkLayer, + DaytonaSnapshotLayer, + InterviewProviderLayer, + LocalSandboxLayer, + NotificationProviderLayer, + RunArtifactsLayer, + RunGoalLayer, + RunPrepareLayer, + ScmGitHubLayer, + ObjectStoreLocalLayer, + ObjectStoreS3Layer, + ServerApiLayer, + ServerAuthGithubLayer, + ServerListenLayer, + ServerLoggingLayer, +); + +impl Combine for RunCheckpointLayer { + fn combine(self, other: Self) -> Self { + if self.exclude_globs.is_empty() { + other + } else { + self + } + } +} + +/// 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 { + fn is_splice(&self) -> bool; +} + +impl SpliceMarker for ModelRefOrSplice { + fn is_splice(&self) -> bool { + matches!(self, Self::Splice) + } +} + +impl SpliceMarker for StringOrSplice { + fn is_splice(&self) -> bool { + matches!(self, Self::Splice) + } +} + +impl Combine for Vec { + fn combine(self, other: Self) -> Self { + splice_combine(other, self) + } +} + +impl Combine for Vec { + fn combine(self, other: Self) -> Self { + combine_hooks(&other, self) + } +} + +fn splice_combine(fallback: Vec, current: Vec) -> Vec { + if current.is_empty() { + return fallback; + } + let Some(pos) = current.iter().position(T::is_splice) else { + return current; + }; + let mut out = Vec::with_capacity(current.len() - 1 + fallback.len()); + for (index, entry) in current.into_iter().enumerate() { + if index == pos { + out.extend(fallback.iter().filter(|entry| !entry.is_splice()).cloned()); + } else if !entry.is_splice() { + out.push(entry); + } + } + out +} + +fn combine_hooks(fallback: &[HookEntry], current: Vec) -> Vec { + let mut out = Vec::with_capacity(fallback.len() + current.len()); + let mut appended_ids = Vec::new(); + + for fallback_entry in fallback { + if let Some(id) = &fallback_entry.id { + if let Some(replacement) = current + .iter() + .find(|entry| entry.id.as_deref() == Some(id.as_str())) + { + out.push(replacement.clone()); + appended_ids.push(id.clone()); + continue; + } + } + out.push(fallback_entry.clone()); + } + + for current_entry in current { + if let Some(id) = ¤t_entry.id { + if appended_ids.contains(id) { + continue; + } + } + out.push(current_entry); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, fabro_macros::Combine)] + struct FieldMergeLayer { + a: Option, + b: Option, + } + + #[derive(Debug, PartialEq)] + struct WholeReplaceLayer { + a: Option, + b: Option, + } + + impl Combine for WholeReplaceLayer { + fn combine(self, _other: Self) -> Self { + self + } + } + + #[track_caller] + fn assert_option_leaf(this: T, fallback: T) + where + T: Clone + std::fmt::Debug + PartialEq, + Option: Combine, + { + assert_eq!( + Some(this.clone()).combine(Some(fallback.clone())), + Some(this) + ); + assert_eq!( + Option::::None.combine(Some(fallback.clone())), + Some(fallback) + ); + } + + #[test] + fn option_leaf_types_prefer_self_or_fallback() { + assert_option_leaf("this".to_string(), "fallback".to_string()); + assert_option_leaf(true, false); + assert_option_leaf(1_u16, 2_u16); + assert_option_leaf(1_u32, 2_u32); + assert_option_leaf(1_u64, 2_u64); + assert_option_leaf(1_usize, 2_usize); + assert_option_leaf(1_i32, 2_i32); + assert_option_leaf(Duration::from_secs(1), Duration::from_secs(2)); + assert_option_leaf(InterpString::parse("this"), InterpString::parse("fallback")); + assert_option_leaf(Size::from_bytes(1), Size::from_bytes(2)); + assert_option_leaf(CliAuthStrategy::None, CliAuthStrategy::Jwt); + assert_option_leaf(OutputFormat::Json, OutputFormat::Text); + assert_option_leaf(OutputVerbosity::Quiet, OutputVerbosity::Verbose); + assert_option_leaf(AgentPermissions::ReadOnly, AgentPermissions::Full); + assert_option_leaf(ApprovalMode::Auto, ApprovalMode::Prompt); + assert_option_leaf(HookAgentMarker::Enabled, HookAgentMarker::Enabled); + assert_option_leaf(HookTlsMode::NoVerify, HookTlsMode::Verify); + assert_option_leaf(MergeStrategy::Rebase, MergeStrategy::Squash); + assert_option_leaf(RunMode::DryRun, RunMode::Normal); + assert_option_leaf(WorktreeMode::Always, WorktreeMode::Never); + assert_option_leaf( + GithubIntegrationStrategy::App, + GithubIntegrationStrategy::Token, + ); + assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local); + assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken); + assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel); + assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]); + assert_option_leaf(vec![ServerAuthMethod::Github], vec![ + ServerAuthMethod::DevToken, + ]); + assert_option_leaf( + HashMap::from([("this".to_string(), toml::Value::String("value".to_string()))]), + HashMap::from([( + "fallback".to_string(), + toml::Value::String("value".to_string()), + )]), + ); + } + + #[test] + fn recursive_option_combines_inner_fields() { + let this = Some(FieldMergeLayer { + a: Some(1), + b: None, + }); + let fallback = Some(FieldMergeLayer { + a: Some(2), + b: Some(3), + }); + + assert_eq!( + this.combine(fallback), + Some(FieldMergeLayer { + a: Some(1), + b: Some(3), + }) + ); + } + + #[test] + fn whole_replace_inner_does_not_inherit_fallback_fields() { + let this = Some(WholeReplaceLayer { + a: Some(1), + b: None, + }); + let fallback = Some(WholeReplaceLayer { + a: Some(2), + b: Some(3), + }); + + assert_eq!( + this.combine(fallback), + Some(WholeReplaceLayer { + a: Some(1), + b: None, + }) + ); + } +} 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-config/src/layers/maps.rs b/lib/crates/fabro-config/src/layers/maps.rs new file mode 100644 index 000000000..f34bdb6a7 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/maps.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; +use std::collections::hash_map::IntoIter; +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Serialize}; + +use super::combine::Combine; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ReplaceMap(pub HashMap); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StickyMap(pub HashMap); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MergeMap(pub HashMap); + +macro_rules! impl_map_wrapper { + ($name:ident) => { + impl $name { + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[must_use] + pub fn into_inner(self) -> HashMap { + self.0 + } + } + + impl Deref for $name { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl From> for $name { + fn from(value: HashMap) -> Self { + Self(value) + } + } + + impl Default for $name { + fn default() -> Self { + Self(HashMap::new()) + } + } + + impl IntoIterator for $name { + type IntoIter = IntoIter; + type Item = (String, V); + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } + } + }; +} + +impl_map_wrapper!(ReplaceMap); +impl_map_wrapper!(StickyMap); +impl_map_wrapper!(MergeMap); + +impl Combine for ReplaceMap { + fn combine(self, other: Self) -> Self { + if self.0.is_empty() { other } else { self } + } +} + +impl Combine for StickyMap { + fn combine(self, other: Self) -> Self { + let mut combined = other.0; + for (key, value) in self.0 { + combined.insert(key, value); + } + Self(combined) + } +} + +impl Combine for MergeMap { + fn combine(self, other: Self) -> Self { + let mut combined = other.0; + for (key, value) in self.0 { + let value = match combined.remove(&key) { + Some(fallback) => value.combine(fallback), + None => value, + }; + combined.insert(key, value); + } + Self(combined) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, fabro_macros::Combine)] + struct ValueLayer { + a: Option, + b: Option, + } + + #[test] + fn replace_map_self_wins_when_non_empty() { + let this = ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])); + let fallback = ReplaceMap(HashMap::from([ + ("a".to_string(), "fallback".to_string()), + ("b".to_string(), "fallback".to_string()), + ])); + + assert_eq!( + this.combine(fallback), + ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])) + ); + } + + #[test] + fn replace_map_empty_self_uses_fallback() { + let this = ReplaceMap::(HashMap::new()); + let fallback = ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])); + + assert_eq!( + this.combine(fallback), + ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])) + ); + } + + #[test] + fn replace_map_round_trips_as_toml_table() { + let parsed: ReplaceMap = + toml::from_str(r#"a = "one""#).expect("fixture should deserialize"); + + assert_eq!( + parsed, + ReplaceMap(HashMap::from([("a".to_string(), "one".to_string())])) + ); + + let serialized = toml::to_string(&parsed).expect("fixture should serialize"); + let reparsed: ReplaceMap = + toml::from_str(&serialized).expect("fixture should deserialize again"); + + assert_eq!(reparsed, parsed); + } + + #[test] + fn sticky_map_merges_keys_with_self_winning_conflicts() { + let this = StickyMap(HashMap::from([ + ("a".to_string(), "this".to_string()), + ("c".to_string(), "this".to_string()), + ])); + let fallback = StickyMap(HashMap::from([ + ("a".to_string(), "fallback".to_string()), + ("b".to_string(), "fallback".to_string()), + ])); + + assert_eq!( + this.combine(fallback), + StickyMap(HashMap::from([ + ("a".to_string(), "this".to_string()), + ("b".to_string(), "fallback".to_string()), + ("c".to_string(), "this".to_string()), + ])) + ); + } + + #[test] + fn merge_map_recursively_combines_values_for_matching_keys() { + let this = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("this".to_string()), + b: None, + })])); + let fallback = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("fallback".to_string()), + b: Some("fallback".to_string()), + })])); + + assert_eq!( + this.combine(fallback), + MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("this".to_string()), + b: Some("fallback".to_string()), + },)])) + ); + } +} 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..8754c9ebf --- /dev/null +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -0,0 +1,38 @@ +pub mod cli; +pub mod combine; +pub mod features; +pub mod maps; +pub mod project; +pub mod run; +pub mod server; +pub mod settings; +pub mod splice_array; +pub 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(crate) use splice_array::{SPLICE_MARKER, SpliceArray}; +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..006efe448 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -0,0 +1,493 @@ +//! 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::ser::SerializeStruct; +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..96a4538d0 --- /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 as DurationLayer, 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-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs new file mode 100644 index 000000000..e665de65e --- /dev/null +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -0,0 +1,133 @@ +//! The top-level sparse settings layer. +//! +//! This struct models a single settings file (`~/.fabro/settings.toml`, +//! `.fabro/project.toml`, or `workflow.toml`) after deserialization. Fields +//! 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; +use super::features::FeaturesLayer; +use super::project::ProjectLayer; +use super::run::RunLayer; +use super::server::ServerLayer; +use super::workflow::WorkflowLayer; + +/// A sparse settings layer before merge/resolve. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +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")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, +} + +impl FromStr for SettingsLayer { + type Err = toml::de::Error; + + fn from_str(source: &str) -> Result { + toml::from_str(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(any(test, feature = "test-support"))] +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 { + let mut layer = Self::default(); + layer.ensure_test_auth_methods(); + layer + } + + /// 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 fabro_types::settings::ServerAuthMethod; + + use super::server::{ServerAuthLayer, ServerLayer as ServerLayerTy}; + + if self + .server + .as_ref() + .and_then(|server| server.auth.as_ref()) + .and_then(|auth| auth.methods.as_ref()) + .is_some() + { + return; + } + let server = self.server.get_or_insert_with(ServerLayerTy::default); + let auth = server.auth.get_or_insert_with(ServerAuthLayer::default); + auth.methods = Some(vec![ServerAuthMethod::DevToken]); + } +} 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..35d312168 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/splice_array.rs @@ -0,0 +1,261 @@ +//! Splice-capable string arrays. +//! +//! In declared splice-capable array paths, the literal string value `"..."` +//! is reserved: it represents "splice in inherited values from lower-precedence +//! layers here." At most one `"..."` marker may appear per array. In the base +//! layer with no inherited parent, the marker resolves to an empty inherited +//! segment. In non-splice paths the same literal is a hard error — enforced +//! by using the plain `Vec` type elsewhere and this type only where +//! splice semantics are explicitly allowed. + +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The reserved literal that marks the splice insertion point. +pub const SPLICE_MARKER: &str = "..."; + +/// A string array that may contain at most one splice marker. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SpliceArray { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Entry { + Value(String), + Splice, +} + +/// An error returned when a splice array fails validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpliceArrayError { + /// The array contained more than one splice marker. + MultipleMarkers, +} + +impl fmt::Display for SpliceArrayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MultipleMarkers => { + f.write_str(r#"splice array must contain at most one "..." marker"#) + } + } + } +} + +impl std::error::Error for SpliceArrayError {} + +impl SpliceArray { + /// Build a splice array from a raw `Vec`. + pub fn from_raw(raw: Vec) -> Result { + let mut entries = Vec::with_capacity(raw.len()); + let mut marker_count = 0; + for item in raw { + if item == SPLICE_MARKER { + marker_count += 1; + entries.push(Entry::Splice); + } else { + entries.push(Entry::Value(item)); + } + } + if marker_count > 1 { + return Err(SpliceArrayError::MultipleMarkers); + } + Ok(Self { entries }) + } + + /// Build a splice array with no inherited splice marker. + #[must_use] + pub fn from_values(values: impl IntoIterator) -> Self { + Self { + entries: values.into_iter().map(Entry::Value).collect(), + } + } + + /// True when the array contains a splice marker. + #[must_use] + pub fn has_splice(&self) -> bool { + self.entries.iter().any(|e| matches!(e, Entry::Splice)) + } + + /// The index of the splice marker, if present. + #[must_use] + pub fn splice_position(&self) -> Option { + self.entries.iter().position(|e| matches!(e, Entry::Splice)) + } + + /// The non-splice values, in source order. + #[must_use] + pub fn values(&self) -> Vec<&str> { + self.entries + .iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v.as_str()), + Entry::Splice => None, + }) + .collect() + } + + /// Resolve this array against an inherited lower-precedence value list. + /// + /// - If the array has a splice marker, the inherited list is spliced in at + /// the marker position. + /// - If the array has no splice marker, it replaces the inherited list + /// wholesale. + #[must_use] + pub fn resolve(self, inherited: Vec) -> Vec { + let Some(pos) = self.splice_position() else { + return self + .entries + .into_iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v), + Entry::Splice => None, + }) + .collect(); + }; + + let mut prefix = Vec::new(); + let mut suffix = Vec::new(); + for (i, entry) in self.entries.into_iter().enumerate() { + match entry { + Entry::Value(v) => { + if i < pos { + prefix.push(v); + } else { + suffix.push(v); + } + } + Entry::Splice => {} + } + } + + let mut out = prefix; + out.extend(inherited); + out.extend(suffix); + out + } +} + +impl Serialize for SpliceArray { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.entries.len()))?; + for entry in &self.entries { + match entry { + Entry::Value(v) => seq.serialize_element(v)?, + Entry::Splice => seq.serialize_element(SPLICE_MARKER)?, + } + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for SpliceArray { + fn deserialize>(deserializer: D) -> Result { + struct SpliceArrayVisitor; + + impl<'de> Visitor<'de> for SpliceArrayVisitor { + type Value = SpliceArray; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + r#"an array of strings, optionally containing a single "..." splice marker"#, + ) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut raw: Vec = Vec::new(); + while let Some(item) = seq.next_element::()? { + raw.push(item); + } + SpliceArray::from_raw(raw).map_err(de::Error::custom) + } + } + + deserializer.deserialize_seq(SpliceArrayVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_raw_with_no_marker() { + let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap(); + assert!(!arr.has_splice()); + assert_eq!(arr.values(), vec!["a", "b"]); + } + + #[test] + fn append_marker_at_front() { + let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(0)); + let resolved = arr.resolve(vec!["a".into(), "b".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn prepend_marker_at_back() { + let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(1)); + let resolved = arr.resolve(vec!["b".into(), "c".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn marker_in_middle() { + let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap(); + let resolved = arr.resolve(vec!["mid".into()]); + assert_eq!(resolved, vec!["pre", "mid", "post"]); + } + + #[test] + fn replace_semantics_without_marker() { + let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap(); + let resolved = arr.resolve(vec!["inherited".into()]); + assert_eq!(resolved, vec!["only"]); + } + + #[test] + fn multiple_markers_rejected() { + let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err(); + assert_eq!(err, SpliceArrayError::MultipleMarkers); + } + + #[test] + fn base_layer_with_splice_resolves_to_empty_inherited() { + let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap(); + let resolved = arr.resolve(vec![]); + assert_eq!(resolved, vec!["b"]); + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + a: SpliceArray, + } + + let input = r#"{"a":["...","b"]}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert!(parsed.a.has_splice()); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } + + #[test] + fn serde_rejects_multiple_markers() { + #[derive(Debug, serde::Deserialize)] + struct Wrap { + _a: SpliceArray, + } + + let input = r#"{"_a":["...","..."]}"#; + let err = serde_json::from_str::(input).unwrap_err(); + assert!(err.to_string().contains("at most one")); + } +} diff --git a/lib/crates/fabro-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 7464751ee..3bfa0f54e 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -8,6 +8,7 @@ extern crate self as fabro_config; pub mod builders; mod defaults; +mod layers; pub mod bind; pub mod daemon; @@ -31,6 +32,23 @@ pub use builders::{ pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; +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(crate) use layers::{Combine, SPLICE_MARKER, SettingsLayer, SpliceArray}; pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ ResolveError, dev_token_auth_enabled, resolve_cli, resolve_features, resolve_project, diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs index 164325bc9..1e0f10c5a 100644 --- a/lib/crates/fabro-config/src/load.rs +++ b/lib/crates/fabro-config/src/load.rs @@ -5,11 +5,10 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::run::RunGoalLayer; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::InterpString; use crate::parse::parse_settings_layer; -use crate::{Error, Result}; +use crate::{Error, Result, RunGoalLayer, SettingsLayer}; 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))?; diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index 219c04939..afa67ba7f 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; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 61239402c..f6f888a3a 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,11 +12,11 @@ use std::fmt::Write; use std::path::{Component, Path, PathBuf}; -use fabro_types::settings::{RunNamespace, SettingsLayer}; +use fabro_types::settings::RunNamespace; use serde::Serialize; use crate::load::load_settings_path; -use crate::{Error, Result, WorkflowSettingsBuilder, run}; +use crate::{Error, Result, RunGoalLayer, SettingsLayer, WorkflowSettingsBuilder, run}; const CONFIG_FILENAME: &str = ".fabro/project.toml"; #[derive(Clone, Debug)] 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/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 ced34eab6..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 { diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 54cbf8ed9..dbbe31dbb 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -1,21 +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; +use crate::{ + IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, + ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, + ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, + ServerLoggingLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, +}; pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { layer 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 5c6bff1dc..17b75a881 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -11,13 +11,11 @@ use std::path::{Path, PathBuf}; -use fabro_types::settings::run::{ - ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunGoalLayer, RunLayer, RunNamespace, -}; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunNamespace}; -use crate::Result; use crate::load::{load_settings_path, resolve_goal_file_path}; +use crate::{Result, RunGoalLayer, RunLayer, SettingsLayer}; /// Load and parse a run config from a TOML file. /// diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 951ae75aa..4fb024d5f 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"; 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/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 636b48a95..ed695ffd6 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -4,7 +4,10 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; -use fabro_config::{WorkflowSettingsBuilder, 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; @@ -14,13 +17,15 @@ use fabro_sandbox::config::{ }; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; -use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; +use fabro_types::settings::ServerNamespace; +#[cfg(test)] +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ - ApprovalMode, DaytonaDockerfileLayer, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource, - RunExecutionLayer, RunGoal, RunLayer, RunMode, RunModelLayer, RunNamespace, RunSandboxLayer, + ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource, RunGoal, RunMode, + RunNamespace, }; -use fabro_types::settings::{ReplaceMap, ServerNamespace, SettingsLayer}; use fabro_types::{RunId, WorkflowSettings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; @@ -52,8 +57,8 @@ struct ManifestSettingsOverrides { cli: Option, } -pub(crate) fn manifest_run_defaults(settings: &SettingsLayer) -> RunLayer { - settings.run.clone().unwrap_or_default() +pub(crate) fn manifest_run_defaults(run: Option<&RunLayer>) -> RunLayer { + run.cloned().unwrap_or_default() } pub(crate) fn prepare_manifest( @@ -217,10 +222,18 @@ fn root_workflow_run_layer( return Ok(RunLayer::default()); }; - let mut layer = parse_settings_layer(&config.source) + let mut document: toml::Table = config + .source + .parse() .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; - resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; - Ok(layer.run.unwrap_or_default()) + let mut run = document + .remove("run") + .map(|value| 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 manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettingsOverrides { @@ -307,14 +320,13 @@ fn resolve_working_directory(settings: &WorkflowSettings, caller_cwd: &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()); diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 0bee1bccb..6f100ff8e 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -6,17 +6,19 @@ use std::time::Duration; use anyhow::Context; use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; -use fabro_config::{Storage, load_config_file, load_server_runtime_settings}; +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::ServerSettings; #[cfg(test)] use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{RunLayer, RunModelLayer, RunSandboxLayer}; -use fabro_types::settings::server::{GithubIntegrationStrategy, ServerWebLayer, WebhookStrategy}; +use fabro_types::settings::server::{GithubIntegrationStrategy, WebhookStrategy}; use fabro_types::settings::{ - GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerLayer, - ServerListenSettings, ServerNamespace, + GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, + ServerNamespace, }; use fabro_util::terminal::Styles; use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey}; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index bc700ba2a..bbaceb58c 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::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{RunSettingsBuilder, ServerSettingsBuilder, Storage}; +use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, Storage}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -64,12 +64,11 @@ use fabro_store::{ }; #[cfg(test)] use fabro_types::BlockedReason; -use fabro_types::settings::run::{RunLayer, RunMode}; +use fabro_types::settings::run::RunMode; use fabro_types::settings::server::{ - GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, - ServerLayer, + GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerLayer, }; -use fabro_types::settings::{InterpString, RunNamespace, SettingsLayer}; +use fabro_types::settings::{InterpString, RunNamespace, ServerAuthMethod, SettingsLayer}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, @@ -1706,12 +1705,27 @@ fn resolve_manifest_run_settings( RunSettingsBuilder::from_run_layer(manifest_run_defaults).map_err(|err| err.to_string()) } +fn settings_toml(layer: &SettingsLayer) -> anyhow::Result { + toml::to_string(layer).map_err(|err| anyhow::anyhow!("failed to serialize settings: {err}")) +} + pub(crate) fn resolve_app_state_settings( layer: &SettingsLayer, ) -> anyhow::Result { - let manifest_run_defaults = run_manifest::manifest_run_defaults(layer); + let manifest_run_defaults = layer + .run + .as_ref() + .map(|run| { + toml::Value::try_from(run) + .map_err(|err| anyhow::anyhow!("failed to serialize run defaults: {err}"))? + .try_into::() + .map_err(|err| anyhow::anyhow!("failed to parse run defaults: {err}")) + }) + .transpose()? + .unwrap_or_default(); + let settings_toml = settings_toml(layer)?; Ok(ResolvedAppStateSettings { - server_settings: ServerSettingsBuilder::from_layer(layer)?, + server_settings: ServerSettingsBuilder::from_toml(&settings_toml)?, manifest_run_settings: resolve_manifest_run_settings(&manifest_run_defaults), manifest_run_defaults, }) diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index c05efff65..c3130f510 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -72,7 +72,7 @@ pub struct CliLoggingSettings { } /// A sparse `[cli]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -120,7 +120,7 @@ pub enum CliAuthStrategy { } /// `[cli.exec]` — `fabro exec` defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliExecLayer { /// Prevent idle sleep on macOS while an exec run is in flight. @@ -132,7 +132,7 @@ pub struct CliExecLayer { pub agent: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliExecModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -141,7 +141,7 @@ pub struct CliExecModelLayer { pub name: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliExecAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -152,7 +152,7 @@ pub struct CliExecAgentLayer { } /// `[cli.output]` — generic CLI output defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliOutputLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -179,7 +179,7 @@ pub enum OutputVerbosity { } /// `[cli.updates]` — upgrade check toggle. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CliUpdatesLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/layer.rs b/lib/crates/fabro-types/src/settings/layer.rs index c3dd77837..188ece46d 100644 --- a/lib/crates/fabro-types/src/settings/layer.rs +++ b/lib/crates/fabro-types/src/settings/layer.rs @@ -15,7 +15,7 @@ use super::server::ServerLayer; use super::workflow::WorkflowLayer; /// A sparse settings layer before merge/resolve. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct SettingsLayer { #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] pub version: Option, diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index f97f2d204..eb1a439be 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -19,7 +19,7 @@ pub struct ProjectNamespace { } /// A sparse `[project]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProjectLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 26d1c1aa3..d0d0a5b01 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -427,7 +427,7 @@ pub struct ArtifactsSettings { } /// A sparse `[run]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -518,7 +518,7 @@ pub enum ResolvedGoalSource { } /// `[run.model]` — provider-neutral default model selection. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -560,14 +560,14 @@ impl<'de> Deserialize<'de> for ModelRefOrSplice { } /// `[run.git]` — local git behavior such as commit author. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunGitLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GitAuthorLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -601,7 +601,7 @@ pub struct PrepareStep { } /// `[run.execution]` — run posture knobs. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunExecutionLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -636,7 +636,7 @@ pub struct RunCheckpointLayer { } /// `[run.sandbox]` — sandbox selection and execution-environment surface. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -671,7 +671,7 @@ pub enum WorktreeMode { Never, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DaytonaSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -718,7 +718,7 @@ pub enum DaytonaNetworkLayer { } /// `[run.notifications.]` — a keyed notification route. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NotificationRouteLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -773,7 +773,7 @@ pub struct NotificationProviderLayer { } /// `[run.interviews]` — external interview delivery. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct InterviewsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -794,7 +794,7 @@ pub struct InterviewProviderLayer { } /// `[run.agent]` — agent knobs only (permissions, MCPs). -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -945,7 +945,7 @@ pub enum HookEvent { } /// `[run.scm]` — remote SCM host/provider behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunScmLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -967,7 +967,7 @@ pub struct RunScmLayer { pub struct ScmGitHubLayer; /// `[run.pull_request]` — provider-neutral PR behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunPullRequestLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 9c31198de..bdce8d470 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -307,7 +307,7 @@ where } /// A sparse `[server]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -359,7 +359,7 @@ pub struct ServerApiLayer { } /// `[server.web]` — web surface settings. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerWebLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -373,7 +373,7 @@ pub struct ServerWebLayer { /// 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)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerAuthLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -389,7 +389,7 @@ pub struct ServerAuthGithubLayer { pub allowed_usernames: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerIpAllowlistLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -398,7 +398,7 @@ pub struct ServerIpAllowlistLayer { pub trusted_proxy_count: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerIpAllowlistOverrideLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -408,7 +408,7 @@ pub struct ServerIpAllowlistOverrideLayer { } /// `[server.storage]` — single managed local disk root. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerStorageLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -416,7 +416,7 @@ pub struct ServerStorageLayer { } /// `[server.artifacts]` — object-store-backed artifact storage. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerArtifactsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -430,7 +430,7 @@ pub struct ServerArtifactsLayer { } /// `[server.slatedb]` — SlateDB bottomless storage plus tunables. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerSlateDbLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -479,7 +479,7 @@ pub struct ObjectStoreS3Layer { } /// `[server.scheduler]` — server-managed execution policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerSchedulerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -498,7 +498,7 @@ pub struct ServerLoggingLayer { /// 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)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerIntegrationsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -513,7 +513,7 @@ pub struct ServerIntegrationsLayer { /// `[server.integrations.github]` — GitHub App, credentials, and inbound /// webhooks. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GithubIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -533,7 +533,7 @@ pub struct GithubIntegrationLayer { } /// `[server.integrations.slack]` — Slack workspace credentials and defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SlackIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -543,7 +543,7 @@ pub struct SlackIntegrationLayer { } /// `[server.integrations.discord]` — Discord workspace configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DiscordIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -551,14 +551,14 @@ pub struct DiscordIntegrationLayer { } /// `[server.integrations.teams]` — Microsoft Teams configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TeamsIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct IntegrationWebhooksLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index 25b1664ba..834ec58ed 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -19,7 +19,7 @@ pub struct WorkflowNamespace { } /// A sparse `[workflow]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkflowLayer { #[serde(default, skip_serializing_if = "Option::is_none")] From 8f47bc931748f4d7e441606865ed4ddc5b5b774e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:31:33 -0400 Subject: [PATCH 45/60] migrate server tests off raw settings layers --- lib/crates/fabro-server/src/run_manifest.rs | 33 +-- lib/crates/fabro-server/src/serve.rs | 201 ++++++-------- lib/crates/fabro-server/src/server.rs | 260 ++++++++++++++---- .../tests/it/api/cli_auth_token.rs | 45 ++- .../fabro-server/tests/it/api/routing.rs | 31 ++- lib/crates/fabro-server/tests/it/api/runs.rs | 9 +- .../fabro-server/tests/it/api/settings.rs | 17 +- .../fabro-server/tests/it/api/system.rs | 14 +- lib/crates/fabro-server/tests/it/api/tcp.rs | 10 +- lib/crates/fabro-server/tests/it/helpers.rs | 108 ++++++-- .../tests/it/openapi_conformance.rs | 10 +- .../tests/it/scenario/lifecycle.rs | 23 +- lib/crates/fabro-types/src/dense.rs | 23 +- 13 files changed, 503 insertions(+), 281 deletions(-) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index ed695ffd6..b0b49f659 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -18,8 +18,6 @@ use fabro_sandbox::config::{ use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::settings::ServerNamespace; -#[cfg(test)] -use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ @@ -973,20 +971,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(|value| 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 = manifest_run_defaults(&server_settings_fixture( + let server_settings = manifest_run_defaults(Some(&server_settings_fixture( r#" _version = 1 @@ -996,7 +997,7 @@ mode = "dry_run" [server.storage] root = "/srv/fabro" "#, - )); + ))); let mut manifest = minimal_manifest(); manifest.args = Some(types::ManifestArgs { auto_approve: None, @@ -1020,7 +1021,7 @@ root = "/srv/fabro" #[test] fn prepare_manifest_prefers_bundled_settings_without_duplication() { - let server_settings = manifest_run_defaults(&server_settings_fixture( + let server_settings = manifest_run_defaults(Some(&server_settings_fixture( r#" _version = 1 @@ -1033,7 +1034,7 @@ script = "cli-setup" [server.integrations.github] app_id = "snapshotted-app-id" "#, - )); + ))); let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().config = @@ -1082,7 +1083,7 @@ app_id = "snapshotted-app-id" async fn invalid_preflight_returns_diagnostics_without_runtime_checks() { let state = crate::server::create_app_state(); let prepared = prepare_manifest( - &manifest_run_defaults(&default_settings_fixture()), + &manifest_run_defaults(Some(&default_settings_fixture())), &invalid_manifest(), ) .unwrap(); @@ -1121,7 +1122,7 @@ enabled = true }); let prepared = prepare_manifest( - &manifest_run_defaults(&default_settings_fixture()), + &manifest_run_defaults(Some(&default_settings_fixture())), &manifest, ) .unwrap(); @@ -1162,7 +1163,7 @@ provider = "daytona" }); let prepared = prepare_manifest( - &manifest_run_defaults(&default_settings_fixture()), + &manifest_run_defaults(Some(&default_settings_fixture())), &manifest, ) .unwrap(); diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 6f100ff8e..f1d9944d0 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -13,8 +13,6 @@ use fabro_config::{ use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_sandbox::SandboxProvider; use fabro_types::ServerSettings; -#[cfg(test)] -use fabro_types::settings::SettingsLayer; use fabro_types::settings::server::{GithubIntegrationStrategy, WebhookStrategy}; use fabro_types::settings::{ GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, @@ -188,19 +186,6 @@ fn serve_overrides(args: &ServeArgs) -> (Option, Option) ) } -#[cfg(test)] -fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLayer { - let (run, server) = serve_overrides(args); - let mut settings = base.clone(); - if let Some(run) = run { - settings.run = Some(run); - } - if let Some(server) = server { - settings.server = Some(server); - } - settings -} - async fn resolve_github_webhook_ip_allowlist( resolved_server_settings: &ServerNamespace, github_meta_resolver: &GitHubMetaResolver, @@ -1063,52 +1048,65 @@ mod tests { use std::time::Duration; use fabro_config::bind::{Bind, BindRequest}; - use fabro_config::{ServerSettingsBuilder, 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::run::RunLayer; use fabro_types::settings::server::ObjectStoreSettings; use fabro_util::Home; use super::{ - GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_serve_overrides, - 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, 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::resolve_app_state_settings; + 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(|value| value.try_into::()) + .transpose() + .expect("run settings should parse") + .unwrap_or_default() } - fn resolved_server_settings(layer: &SettingsLayer) -> fabro_types::settings::ServerNamespace { - ServerSettingsBuilder::from_layer(layer) - .expect("settings should resolve") - .server + 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 runtime_server_settings_preserve_storage_dir_override() { - let base = parse_settings("_version = 1\n"); - 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 mut resolved = - resolve_app_state_settings(&apply_serve_overrides(&base, &args)).expect("settings"); + let mut resolved = resolved_runtime_settings("_version = 1\n"); resolved.server_settings = resolved .server_settings .with_storage_override(&PathBuf::from("/srv/fabro-storage")); @@ -1117,11 +1115,23 @@ mod tests { 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"); + }; + 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 runtime_server_settings_keep_disk_defaults_out_of_manifest_defaults() { - let base = parse_settings( + let mut resolved = resolved_runtime_settings( r#" _version = 1 @@ -1129,21 +1139,6 @@ _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 mut resolved = - resolve_app_state_settings(&apply_serve_overrides(&base, &args)).expect("settings"); resolved.server_settings = resolved .server_settings .with_storage_override(&PathBuf::from("/srv/from-runtime")); @@ -1154,21 +1149,13 @@ root = "/srv/from-disk" ); assert_eq!( resolved.manifest_run_defaults, - RunLayer::default(), + 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, @@ -1182,11 +1169,10 @@ enabled = false watch_web: false, }; - let resolved = apply_serve_overrides(&base, &args); + let (_, server) = serve_overrides(&args); assert_eq!( - resolved - .server + server .as_ref() .and_then(|server| server.web.as_ref()) .and_then(|web| web.enabled), @@ -1196,7 +1182,6 @@ enabled = false #[test] fn apply_runtime_settings_disables_web_from_cli_flag() { - let base = SettingsLayer::default(); let args = ServeArgs { bind: None, model: None, @@ -1210,11 +1195,10 @@ enabled = false watch_web: false, }; - let resolved = apply_serve_overrides(&base, &args); + let (_, server) = serve_overrides(&args); assert_eq!( - resolved - .server + server .as_ref() .and_then(|server| server.web.as_ref()) .and_then(|web| web.enabled), @@ -1224,12 +1208,9 @@ enabled = false #[test] fn resolve_bind_request_from_server_settings_defaults_to_socket_when_listen_is_absent() { - let bind = resolve_bind_request_from_server_settings( - &ServerSettingsBuilder::from_layer(&SettingsLayer::test_default()) - .expect("settings should resolve"), - None, - ) - .expect("bind"); + let 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())); } @@ -1237,7 +1218,7 @@ enabled = false #[test] fn resolve_bind_request_from_server_settings_uses_configured_tcp_when_no_explicit_bind_is_given() { - let settings = parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1247,18 +1228,14 @@ address = "127.0.0.1:0" "#, ); - let bind = resolve_bind_request_from_server_settings( - &ServerSettingsBuilder::from_layer(&settings).expect("settings should resolve"), - 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_server_settings_prefers_explicit_bind_over_config() { - let settings = parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1268,19 +1245,15 @@ address = "127.0.0.1:32276" "#, ); - let bind = resolve_bind_request_from_server_settings( - &ServerSettingsBuilder::from_layer(&settings).expect("settings should resolve"), - 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_server_settings_preserves_host_only_cli_bind() { - let settings = - ServerSettingsBuilder::from_layer(&SettingsLayer::test_default()).expect("settings"); + let settings = server_settings("_version = 1\n"); let bind = resolve_bind_request_from_server_settings(&settings, Some("127.0.0.1")).expect("bind"); @@ -1290,7 +1263,7 @@ address = "127.0.0.1:32276" #[test] fn web_enabled_stays_enabled_without_github_app_mode() { - let base = parse_settings( + let base = server_settings( r#" _version = 1 @@ -1302,7 +1275,7 @@ strategy = "token" "#, ); - let resolved = resolved_server_settings(&base); + let resolved = base.server; assert!(resolved.web.enabled); } @@ -1356,7 +1329,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 @@ -1364,9 +1337,8 @@ _version = 1 root = "{}" "#, root.display() - )); - - let resolved = resolved_server_settings(&settings); + )) + .server; let (_object_store, prefix, flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1378,16 +1350,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 = resolved_server_settings(&settings); + ) + .server; let (_object_store, _prefix, _flush_interval, disk_cache) = build_slatedb_store(&resolved).expect("slatedb store should build"); @@ -1511,7 +1482,7 @@ disk_cache = true #[tokio::test] async fn resolve_github_webhook_ip_allowlist_propagates_resolution_errors() { - let settings = resolved_server_settings(&parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1526,7 +1497,8 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )); + ) + .server; let cache_dir = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1547,7 +1519,7 @@ entries = ["github_meta_hooks"] #[tokio::test] async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() { - let settings = resolved_server_settings(&parse_settings( + let settings = server_settings( r#" _version = 1 @@ -1562,7 +1534,8 @@ app_id = "123" [server.integrations.github.webhooks.ip_allowlist] entries = ["github_meta_hooks"] "#, - )); + ) + .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 bbaceb58c..d53f14599 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2481,6 +2481,121 @@ pub fn create_app_state_with_options( .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_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, + 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_runtime_settings_and_env_lookup_and_server_secret_env( + server_settings, + manifest_run_defaults, + max_concurrent_runs, + env_lookup, + &HashMap::new(), + ) +} + +#[doc(hidden)] +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 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, + 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( settings: SettingsLayer, @@ -2640,6 +2755,33 @@ fn create_app_state_with_store_and_env_lookup( build_app_state(config).expect("test app state should build") } +#[doc(hidden)] +pub fn create_app_state_with_store_and_runtime_settings( + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, + max_concurrent_runs: usize, + store: Arc, + artifact_store: ArtifactStore, +) -> Arc { + 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, HashMap::new()), + env_lookup: default_env_lookup(), + http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + }) + .expect("test app state should build") +} + fn default_env_lookup() -> EnvLookup { Arc::new(|name| std::env::var(name).ok()) } @@ -7398,6 +7540,26 @@ mod tests { const WRONG_DEV_TOKEN: &str = "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + fn settings_layer_from_toml(source: &str) -> SettingsLayer { + let mut settings: SettingsLayer = toml::from_str(source).expect("settings should parse"); + settings.ensure_test_auth_methods(); + settings + } + + 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(|value| 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 test_app_with() -> Router { let state = create_app_state(); build_router(state, AuthMode::Disabled) @@ -7492,7 +7654,7 @@ mod tests { } fn canonical_origin_settings(url: &str) -> SettingsLayer { - fabro_config::parse_settings_layer(&format!( + settings_layer_from_toml(&format!( r#" _version = 1 @@ -7503,7 +7665,6 @@ methods = ["dev-token"] url = "{url}" "# )) - .expect("settings fixture should parse") } #[test] @@ -7537,7 +7698,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( + settings_layer_from_toml( r#" _version = 1 @@ -7550,12 +7711,11 @@ url = "http://old.example.com" [server.storage] root = "/srv/old" "#, - ) - .expect("settings fixture should parse"), + ), 5, ); - let updated = fabro_config::parse_settings_layer( + let updated = settings_layer_from_toml( r#" _version = 1 @@ -7571,8 +7731,7 @@ mode = "dry_run" [server.storage] root = "/srv/new" "#, - ) - .expect("settings fixture should parse"); + ); state .replace_runtime_settings( @@ -7606,7 +7765,7 @@ root = "/srv/new" #[test] fn replace_settings_caches_invalid_manifest_run_settings_tolerantly() { let state = create_app_state_with_options( - fabro_config::parse_settings_layer( + settings_layer_from_toml( r#" _version = 1 @@ -7616,12 +7775,11 @@ methods = ["dev-token"] [server.web] url = "http://old.example.com" "#, - ) - .expect("settings fixture should parse"), + ), 5, ); - let updated = fabro_config::parse_settings_layer( + let updated = settings_layer_from_toml( r#" _version = 1 @@ -7634,8 +7792,7 @@ url = "http://new.example.com" [run.sandbox] provider = "invalid-provider" "#, - ) - .expect("settings fixture should parse"); + ); state .replace_runtime_settings( @@ -7652,8 +7809,7 @@ provider = "invalid-provider" #[test] fn system_features_use_dense_server_and_manifest_defaults() { - let settings = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 [server.auth] @@ -7664,13 +7820,11 @@ session_sandboxes = true [run.execution] retros = false -"#, - ) - .expect("settings fixture should parse"); - let server_settings = - ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); - let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); +"#; + 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)); @@ -7679,8 +7833,7 @@ retros = false #[test] fn system_features_default_retros_when_manifest_run_settings_do_not_resolve() { - let settings = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 [server.auth] @@ -7691,13 +7844,11 @@ session_sandboxes = true [run.sandbox] provider = "invalid-provider" -"#, - ) - .expect("settings fixture should parse"); - let server_settings = - ServerSettingsBuilder::from_layer(&settings).expect("server settings should resolve"); - let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); +"#; + 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)); @@ -7706,34 +7857,30 @@ provider = "invalid-provider" #[test] fn system_sandbox_provider_uses_manifest_defaults() { - let settings = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 [run.sandbox] provider = "daytona" -"#, - ) - .expect("settings fixture should parse"); - let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); +"#; + 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 settings = fabro_config::parse_settings_layer( - r#" + let source = r#" _version = 1 [run.sandbox] provider = "invalid-provider" -"#, - ) - .expect("settings fixture should parse"); - let manifest_run_settings = - resolve_manifest_run_settings(&run_manifest::manifest_run_defaults(&settings)); +"#; + 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), @@ -7745,7 +7892,6 @@ provider = "invalid-provider" 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")) @@ -8095,7 +8241,7 @@ provider = "invalid-provider" ) -> 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 settings = settings_layer_from_toml(&format!( r#" _version = 1 @@ -8114,8 +8260,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(), @@ -8487,7 +8632,7 @@ allowed_usernames = ["octocat"] #[tokio::test] async fn auth_login_github_redirects_to_github() { - let settings: SettingsLayer = fabro_config::parse_settings_layer( + let settings = settings_layer_from_toml( r#" _version = 1 @@ -8500,8 +8645,7 @@ 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, @@ -10149,7 +10293,7 @@ slug = "fabro" #[tokio::test] async fn start_run_persists_full_settings_snapshot() { - let settings: SettingsLayer = fabro_config::parse_settings_layer( + let settings = settings_layer_from_toml( r#" _version = 1 @@ -10187,8 +10331,7 @@ url = "http://api.example.test" [server.logging] level = "debug" "#, - ) - .expect("fixture should parse"); + ); let state = create_app_state_with_options(settings, 5); let app = build_router(Arc::clone(&state), AuthMode::Disabled); @@ -10742,7 +10885,7 @@ 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( + let settings = settings_layer_from_toml( r#" _version = 1 @@ -10752,8 +10895,7 @@ 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) }); 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 57ca32e2b..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::{ServerSettingsBuilder, parse_settings_layer}; 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,18 +27,17 @@ fn test_app(settings: fabro_types::settings::SettingsLayer) -> (axum::Router, Ar None, )); let artifact_store = ArtifactStore::new(object_store, "artifacts"); - let resolved = ServerSettingsBuilder::from_layer(&settings) - .expect("settings should resolve") - .server; - 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, @@ -62,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 @@ -78,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 { @@ -129,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 @@ -145,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/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index d6db8479d..153c39337 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -4,17 +4,18 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Method, Request, StatusCode}; -use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; +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"; @@ -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 14b90af8d..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_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); 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 a182a8a64..0b21583e3 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -106,7 +106,15 @@ async fn spawn_served_listener( .await }); - let bind = rx.await.expect("server should report its bind address"); + let bind = match rx.await { + Ok(bind) => bind, + Err(_) => { + let result = handle + .await + .expect("server task should not panic before reporting readiness"); + panic!("server should report its bind address: {result:?}"); + } + }; (handle, bind, tempdir) } diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index b082c778c..667ca826d 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,34 +30,80 @@ 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(|value| 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 { - sandbox: Some(RunSandboxLayer { - local: Some(LocalSandboxLayer { - worktree_mode: Some(WorktreeMode::Never), - }), - ..RunSandboxLayer::default() +pub(crate) fn test_settings() -> TestAppSettings { + let mut settings = TestAppSettings::default(); + settings.manifest_run_defaults = RunLayer { + sandbox: Some(RunSandboxLayer { + local: Some(LocalSandboxLayer { + worktree_mode: Some(WorktreeMode::Never), }), - ..RunLayer::default() + ..RunSandboxLayer::default() }), - ..SettingsLayer::default() - } + ..RunLayer::default() + }; + settings } pub(crate) fn test_app_with_scheduler(state: Arc) -> axum::Router { @@ -64,17 +112,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 a39565f86..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)]), 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-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs index 2cc6fda8b..876daf05c 100644 --- a/lib/crates/fabro-types/src/dense.rs +++ b/lib/crates/fabro-types/src/dense.rs @@ -4,8 +4,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, - WorkflowNamespace, + CliNamespace, FeaturesNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, + RunNamespace, ServerNamespace, WorkflowNamespace, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -18,10 +18,29 @@ 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, From db132d11a7056e00a56ea5c4e097c3eb12c00da5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:37:37 -0400 Subject: [PATCH 46/60] migrate cli install tests off sparse settings layers --- lib/crates/fabro-cli/src/command_context.rs | 3 +- lib/crates/fabro-cli/src/commands/install.rs | 137 +++++++++--------- lib/crates/fabro-cli/tests/it/cmd/config.rs | 16 +- .../tests/it/support/auth_harness.rs | 23 +-- lib/crates/fabro-install/src/lib.rs | 12 +- 5 files changed, 96 insertions(+), 95 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 6dbf54d6b..8be555870 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -215,7 +215,8 @@ fn resolve_command_settings(loaded_settings: LoadedSettings) -> Result { - 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] @@ -2103,13 +2093,27 @@ name = "custom" ); } - fn parse_install_settings(source: &str) -> fabro_types::settings::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 @@ -2117,12 +2121,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 @@ -2130,12 +2134,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 @@ -2143,19 +2150,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] diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index d06395688..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,15 +65,12 @@ 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::ServerSettingsBuilder::from_layer(&server_settings_layer_fixture()) - .expect("server settings fixture should resolve"); + 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") } 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 110b7ab79..91bc83432 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::{ServerSettingsBuilder, parse_settings_layer}; +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 fabro_types::RunAuthMethod; @@ -71,9 +71,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 = ServerSettingsBuilder::from_layer(&settings) - .expect("settings should resolve") - .server; + 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()), @@ -95,8 +93,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, @@ -357,13 +360,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 @@ -380,7 +383,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-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index 3e21fa101..84bf86cce 100644 --- a/lib/crates/fabro-install/src/lib.rs +++ b/lib/crates/fabro-install/src/lib.rs @@ -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] From 8f4345b43f6a5177c28418f4deb67c1d54fdc34b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:42:01 -0400 Subject: [PATCH 47/60] migrate workflow operation tests off sparse settings layers --- .../fabro-workflow/src/operations/create.rs | 134 +++++++----------- .../fabro-workflow/src/operations/start.rs | 49 +++---- 2 files changed, 73 insertions(+), 110 deletions(-) diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index c1d73e0de..0ded3d72f 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -406,10 +406,13 @@ mod tests { use std::time::Duration; use chrono::{Local, TimeZone, Utc}; - use fabro_config::WorkflowSettingsBuilder; + use fabro_config::{ + ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPullRequestLayer, + WorkflowSettingsBuilder, + }; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; - use fabro_types::settings::{InterpString, SettingsLayer}; + use fabro_types::settings::{InterpString, run::RunMode}; use fabro_types::{WorkflowSettings, fixtures}; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; @@ -426,13 +429,17 @@ mod tests { )) } - fn settings_from_layer(mut layer: SettingsLayer) -> WorkflowSettings { - layer.ensure_test_auth_methods(); - WorkflowSettingsBuilder::from_layer(&layer).expect("settings should resolve") + fn settings_from_run_layer(run: RunLayer) -> WorkflowSettings { + WorkflowSettingsBuilder::new() + .run_overrides(run) + .build() + .expect("settings should resolve") } fn test_default_settings() -> WorkflowSettings { - settings_from_layer(SettingsLayer::test_default()) + WorkflowSettingsBuilder::new() + .build() + .expect("default settings should resolve") } fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { @@ -532,17 +539,13 @@ mod tests { }"#; let validated = validate_dot( dot, - settings_from_layer({ - use fabro_types::settings::run::{RunGoalLayer, RunLayer}; + settings_from_run_layer({ let mut inputs = std::collections::HashMap::new(); inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); - SettingsLayer { - run: Some(RunLayer { - goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), - inputs: Some(inputs), - ..RunLayer::default() - }), - ..SettingsLayer::default() + RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), + inputs: Some(inputs), + ..RunLayer::default() } }), ); @@ -793,35 +796,26 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: settings_from_layer({ - 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 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 + 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()), @@ -921,20 +915,15 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: settings_from_layer({ - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let 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 + ..RunLayer::default() + } }), cwd: dir.path().to_path_buf(), workflow_slug: None, @@ -1004,37 +993,22 @@ mod tests { } fn dry_run_only_settings() -> WorkflowSettings { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - settings_from_layer(SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() + settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() }), - ..SettingsLayer::default() + ..RunLayer::default() }) } - fn dry_run_with_storage(storage_dir: &Path) -> WorkflowSettings { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; - settings_from_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() + ..RunLayer::default() }) } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index e5d02af55..f65d90452 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -969,11 +969,10 @@ mod tests { use std::time::Duration; use chrono::Utc; - use fabro_config::WorkflowSettingsBuilder; + use fabro_config::{RunExecutionLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_store::Database; - use fabro_types::settings::SettingsLayer; - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use fabro_types::{WorkflowSettings, fixtures}; + use fabro_types::settings::run::RunMode; use object_store::memory::InMemory; use super::*; @@ -1012,9 +1011,11 @@ mod tests { (storage_root, run_dir) } - fn settings_from_layer(mut layer: SettingsLayer) -> WorkflowSettings { - layer.ensure_test_auth_methods(); - WorkflowSettingsBuilder::from_layer(&layer).expect("settings should resolve") + 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) { @@ -1026,18 +1027,12 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: settings_from_layer({ - let layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer + settings: settings_from_run_layer(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() }), cwd: storage_root .parent() @@ -1213,18 +1208,12 @@ mod tests { .unwrap() .clone(), ), - settings: settings_from_layer({ - let layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - 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()), From 4f1c5f1f52e1090cf8672b69f7c8babb66cdd1d0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:46:15 -0400 Subject: [PATCH 48/60] migrate server auth tests to dense runtime settings --- lib/crates/fabro-server/src/auth/cli_flow.rs | 58 +++++++------- lib/crates/fabro-server/src/auth/translate.rs | 20 ++++- lib/crates/fabro-server/src/server.rs | 41 ++++++++++ lib/crates/fabro-server/src/web_auth.rs | 78 ++++++++++--------- 4 files changed, 128 insertions(+), 69 deletions(-) diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index 4b052a40d..c2bdc3d61 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -1299,14 +1299,11 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::http::{HeaderMap, Request, StatusCode, header}; use axum_extra::extract::cookie::Key; + use fabro_config::{RunLayer, ServerSettingsBuilder}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; 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/server.rs b/lib/crates/fabro-server/src/server.rs index d53f14599..5a1826faa 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2629,6 +2629,47 @@ pub fn create_app_state_with_env_lookup_and_server_secret_env( build_app_state(config).expect("test app state should build") } +#[cfg(test)] +#[expect( + clippy::disallowed_methods, + reason = "test helper writes a fixture server.env with sync std::fs::write" +)] +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(); + let server_env_path = vault_path + .parent() + .expect("test secrets path should have parent") + .join("server.env"); + if let Some(session_secret) = session_secret { + std::fs::write( + &server_env_path, + format!("SESSION_SECRET={session_secret}\n"), + ) + .expect("test server env should be writable"); + } + let (store, artifact_store) = test_store_bundle(); + let env_lookup = default_env_lookup(); + build_app_state(AppStateConfig { + resolved_settings: resolved_runtime_settings_for_tests( + server_settings, + manifest_run_defaults, + ), + 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, + http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + }) + .expect("test app state should build") +} + #[cfg(test)] #[expect( clippy::disallowed_methods, 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( From b0da8308d496650a48d240c6346258e65d3d779e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 18:54:31 -0400 Subject: [PATCH 49/60] drop server raw settings test helpers --- lib/crates/fabro-server/src/server.rs | 398 ++++++++++++-------------- 1 file changed, 181 insertions(+), 217 deletions(-) diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 5a1826faa..cd2e17365 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -65,10 +65,8 @@ use fabro_store::{ #[cfg(test)] use fabro_types::BlockedReason; use fabro_types::settings::run::RunMode; -use fabro_types::settings::server::{ - GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerLayer, -}; -use fabro_types::settings::{InterpString, RunNamespace, ServerAuthMethod, SettingsLayer}; +use fabro_types::settings::server::{GithubIntegrationSettings, GithubIntegrationStrategy}; +use fabro_types::settings::{InterpString, RunNamespace, ServerAuthMethod}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, @@ -1705,30 +1703,16 @@ fn resolve_manifest_run_settings( RunSettingsBuilder::from_run_layer(manifest_run_defaults).map_err(|err| err.to_string()) } -fn settings_toml(layer: &SettingsLayer) -> anyhow::Result { - toml::to_string(layer).map_err(|err| anyhow::anyhow!("failed to serialize settings: {err}")) -} +fn default_test_server_settings() -> ServerSettings { + ServerSettingsBuilder::from_toml( + r#" +_version = 1 -pub(crate) fn resolve_app_state_settings( - layer: &SettingsLayer, -) -> anyhow::Result { - let manifest_run_defaults = layer - .run - .as_ref() - .map(|run| { - toml::Value::try_from(run) - .map_err(|err| anyhow::anyhow!("failed to serialize run defaults: {err}"))? - .try_into::() - .map_err(|err| anyhow::anyhow!("failed to parse run defaults: {err}")) - }) - .transpose()? - .unwrap_or_default(); - let settings_toml = settings_toml(layer)?; - Ok(ResolvedAppStateSettings { - server_settings: ServerSettingsBuilder::from_toml(&settings_toml)?, - manifest_run_settings: resolve_manifest_run_settings(&manifest_run_defaults), - manifest_run_defaults, - }) +[server.auth] +methods = ["dev-token"] +"#, + ) + .expect("default test server settings should resolve") } fn system_sandbox_provider( @@ -2429,56 +2413,60 @@ 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( @@ -2598,35 +2586,34 @@ pub fn create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_e #[doc(hidden)] pub fn create_app_state_with_env_lookup( - settings: SettingsLayer, + 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( + server_settings, + manifest_run_defaults, max_concurrent_runs, env_lookup, - &HashMap::new(), ) } #[doc(hidden)] pub fn create_app_state_with_env_lookup_and_server_secret_env( - settings: SettingsLayer, + 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") + 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)] @@ -2676,40 +2663,31 @@ pub(crate) fn create_test_app_state_with_runtime_settings_and_session_key( 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, + server_settings: ServerSettings, + manifest_run_defaults: RunLayer, session_secret: Option<&str>, ) -> Arc { - let vault_path = test_secret_store_path(); - let server_env_path = vault_path - .parent() - .expect("test secrets path should have parent") - .join("server.env"); - if let Some(session_secret) = session_secret { - std::fs::write( - &server_env_path, - format!("SESSION_SECRET={session_secret}\n"), - ) - .expect("test server env should be writable"); - } - 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 { - resolved_settings: { - let settings = settings.read().expect("settings lock poisoned"); - resolve_app_state_settings(&settings).expect("test settings should resolve") - }, - registry_factory_override: None, - max_concurrent_runs: 5, + 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, - vault_path, - server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), - env_lookup, - http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), - }) - .expect("test app state should build") + ) } fn test_store_bundle() -> (Arc, ArtifactStore) { @@ -2724,78 +2702,6 @@ fn test_store_bundle() -> (Arc, ArtifactStore) { (store, artifact_store) } -fn default_test_app_state_config( - settings: Arc>, - max_concurrent_runs: usize, - env_lookup: EnvLookup, -) -> AppStateConfig { - ensure_test_auth_methods(&settings); - 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"); - AppStateConfig { - resolved_settings: { - let settings = settings.read().expect("settings lock poisoned"); - resolve_app_state_settings(&settings).expect("test settings should resolve") - }, - 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, - 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") -} - #[doc(hidden)] pub fn create_app_state_with_store_and_runtime_settings( server_settings: ServerSettings, @@ -7581,12 +7487,6 @@ mod tests { const WRONG_DEV_TOKEN: &str = "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; - fn settings_layer_from_toml(source: &str) -> SettingsLayer { - let mut settings: SettingsLayer = toml::from_str(source).expect("settings should parse"); - settings.ensure_test_auth_methods(); - settings - } - fn manifest_run_defaults_from_toml(source: &str) -> fabro_config::RunLayer { let mut document: toml::Table = source.parse().expect("run defaults should parse"); document @@ -7601,6 +7501,13 @@ mod tests { 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) @@ -7651,7 +7558,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)]), @@ -7694,8 +7602,8 @@ mod tests { }) } - fn canonical_origin_settings(url: &str) -> SettingsLayer { - settings_layer_from_toml(&format!( + fn canonical_origin_settings(url: &str) -> ServerSettings { + server_settings_from_toml(&format!( r#" _version = 1 @@ -7713,6 +7621,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(); @@ -7720,10 +7629,19 @@ url = "{url}" }, ); - let err = - resolve_app_state_settings(&canonical_origin_settings("{{ env.FABRO_WEB_URL }}")) - .and_then(|resolved| state.replace_runtime_settings(resolved)) - .expect_err("invalid canonical origin should be rejected"); + let err = state + .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() .contains("server.web.url is required and must be an absolute http(s) URL"), @@ -7739,7 +7657,21 @@ url = "{url}" #[test] fn replace_settings_updates_layer_and_typed_server_settings() { let state = create_app_state_with_options( - settings_layer_from_toml( + server_settings_from_toml( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://old.example.com" + +[server.storage] +root = "/srv/old" +"#, + ), + manifest_run_defaults_from_toml( r#" _version = 1 @@ -7756,8 +7688,7 @@ root = "/srv/old" 5, ); - let updated = settings_layer_from_toml( - r#" + let updated = r#" _version = 1 [server.auth] @@ -7771,13 +7702,10 @@ mode = "dry_run" [server.storage] root = "/srv/new" -"#, - ); +"#; state - .replace_runtime_settings( - resolve_app_state_settings(&updated).expect("updated settings should resolve"), - ) + .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"); @@ -7806,7 +7734,18 @@ root = "/srv/new" #[test] fn replace_settings_caches_invalid_manifest_run_settings_tolerantly() { let state = create_app_state_with_options( - settings_layer_from_toml( + 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 @@ -7820,8 +7759,7 @@ url = "http://old.example.com" 5, ); - let updated = settings_layer_from_toml( - r#" + let updated = r#" _version = 1 [server.auth] @@ -7832,13 +7770,10 @@ url = "http://new.example.com" [run.sandbox] provider = "invalid-provider" -"#, - ); +"#; state - .replace_runtime_settings( - resolve_app_state_settings(&updated).expect("updated settings should resolve"), - ) + .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"); @@ -8282,7 +8217,7 @@ provider = "invalid-provider" ) -> Arc { let dev_token = dev_token.map(str::to_owned); std::fs::create_dir_all(storage_dir).unwrap(); - let settings = settings_layer_from_toml(&format!( + let source = format!( r#" _version = 1 @@ -8301,7 +8236,7 @@ allowed_usernames = ["octocat"] .map(|method| format!("\"{method}\"")) .collect::>() .join(", ") - )); + ); let runtime_directory = Storage::new(storage_dir).runtime_directory(); ServerDaemon::new( std::process::id(), @@ -8315,7 +8250,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, @@ -8579,7 +8515,12 @@ allowed_usernames = ["octocat"] #[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() @@ -8597,7 +8538,12 @@ allowed_usernames = ["octocat"] #[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() @@ -8673,23 +8619,28 @@ allowed_usernames = ["octocat"] #[tokio::test] async fn auth_login_github_redirects_to_github() { - let settings = settings_layer_from_toml( - 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" -"#, - ); +"#; 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 { @@ -9199,7 +9150,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( @@ -10334,10 +10286,12 @@ slug = "fabro" #[tokio::test] async fn start_run_persists_full_settings_snapshot() { - let settings = settings_layer_from_toml( - r#" + let source = r#" _version = 1 +[server.auth] +methods = ["dev-token"] + [run.execution] mode = "dry_run" @@ -10371,9 +10325,12 @@ url = "http://api.example.test" [server.logging] level = "debug" -"#, +"#; + let state = create_app_state_with_options( + server_settings_from_toml(source), + manifest_run_defaults_from_toml(source), + 5, ); - let state = create_app_state_with_options(settings, 5); let app = build_router(Arc::clone(&state), AuthMode::Disabled); let req = Request::builder() @@ -10926,20 +10883,23 @@ level = "debug" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancel_during_startup_persists_cancelled_reason() { - let settings = settings_layer_from_toml( - r#" + let source = r#" _version = 1 +[server.auth] +methods = ["dev-token"] + [[run.prepare.steps]] script = "sleep 5" [run.prepare] timeout = "30s" -"#, +"#; + 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 state = create_app_state_with_settings_and_registry_factory(settings, |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; @@ -11085,7 +11045,11 @@ 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 From 73a47c12562b3a188b955ee74d06916dbda241c5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:04:57 -0400 Subject: [PATCH 50/60] move fabro-config hidden settings tests in-crate --- lib/crates/fabro-config/src/builders.rs | 22 +++++++++---------- lib/crates/fabro-config/src/layers/mod.rs | 21 +++++++++--------- lib/crates/fabro-config/src/layers/run.rs | 1 - lib/crates/fabro-config/src/lib.rs | 11 ++++++---- lib/crates/fabro-config/src/parse.rs | 2 +- lib/crates/fabro-config/src/project.rs | 8 +++---- lib/crates/fabro-config/src/resolve/mod.rs | 3 ++- lib/crates/fabro-config/src/resolve/server.rs | 8 +++---- lib/crates/fabro-config/src/run.rs | 7 +++--- .../fabro-config/{ => src}/tests/combine.rs | 7 +++--- .../fabro-config/{ => src}/tests/defaults.rs | 8 ++++--- lib/crates/fabro-config/src/tests/mod.rs | 9 ++++++++ .../{ => src}/tests/resolve_cli.rs | 5 +++-- .../{ => src}/tests/resolve_features.rs | 3 +-- .../{ => src}/tests/resolve_project.rs | 3 +-- .../{ => src}/tests/resolve_root.rs | 5 +++-- .../{ => src}/tests/resolve_run.rs | 5 +++-- .../{ => src}/tests/resolve_server.rs | 20 ++++++++--------- .../{ => src}/tests/resolve_workflow.rs | 3 +-- lib/crates/fabro-config/src/user.rs | 2 +- 20 files changed, 84 insertions(+), 69 deletions(-) rename lib/crates/fabro-config/{ => src}/tests/combine.rs (96%) rename lib/crates/fabro-config/{ => src}/tests/defaults.rs (94%) create mode 100644 lib/crates/fabro-config/src/tests/mod.rs rename lib/crates/fabro-config/{ => src}/tests/resolve_cli.rs (97%) rename lib/crates/fabro-config/{ => src}/tests/resolve_features.rs (87%) rename lib/crates/fabro-config/{ => src}/tests/resolve_project.rs (92%) rename lib/crates/fabro-config/{ => src}/tests/resolve_root.rs (97%) rename lib/crates/fabro-config/{ => src}/tests/resolve_run.rs (93%) rename lib/crates/fabro-config/{ => src}/tests/resolve_server.rs (96%) rename lib/crates/fabro-config/{ => src}/tests/resolve_workflow.rs (92%) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 3c6214fd6..b91655dbe 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -78,7 +78,7 @@ impl ServerSettingsBuilder { Self::from_layer(&layer) } - pub fn from_layer(layer: &SettingsLayer) -> Result { + 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); @@ -126,7 +126,7 @@ impl UserSettingsBuilder { Self::from_layer_with_cli_overrides(&layer, cli) } - pub fn from_layer(layer: &SettingsLayer) -> Result { + 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); @@ -138,7 +138,7 @@ impl UserSettingsBuilder { ) } - pub fn from_layer_with_cli_overrides( + pub(crate) fn from_layer_with_cli_overrides( layer: &SettingsLayer, cli: &CliLayer, ) -> Result { @@ -171,7 +171,7 @@ impl RunSettingsBuilder { Self::from_layer(&layer) } - pub fn from_layer(layer: &SettingsLayer) -> Result { + 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); @@ -268,13 +268,13 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub fn args_layer(mut self, layer: SettingsLayer) -> Self { + pub(crate) fn args_layer(mut self, layer: SettingsLayer) -> Self { self.args = layer; self } #[must_use] - pub fn workflow_layer(mut self, layer: SettingsLayer) -> Self { + pub(crate) fn workflow_layer(mut self, layer: SettingsLayer) -> Self { self.workflow = layer; self } @@ -298,7 +298,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub fn project_layer(mut self, layer: SettingsLayer) -> Self { + pub(crate) fn project_layer(mut self, layer: SettingsLayer) -> Self { self.project = layer; self } @@ -314,7 +314,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub fn user_layer(mut self, layer: SettingsLayer) -> Self { + pub(crate) fn user_layer(mut self, layer: SettingsLayer) -> Self { self.user = layer; self } @@ -330,7 +330,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub fn server_layer(mut self, layer: SettingsLayer) -> Self { + pub(crate) fn server_layer(mut self, layer: SettingsLayer) -> Self { self.server = layer; self } @@ -360,7 +360,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub fn build_layer(self) -> SettingsLayer { + pub(crate) fn build_layer(self) -> SettingsLayer { let server_defaults = SettingsLayer { version: self.server.version, run: self.server.run, @@ -383,7 +383,7 @@ impl WorkflowSettingsBuilder { Self::from_layer(&self.build_layer()) } - pub fn from_layer( + pub(crate) fn from_layer( layer: &SettingsLayer, ) -> std::result::Result { let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index 8754c9ebf..331c6a67c 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -1,13 +1,13 @@ -pub mod cli; -pub mod combine; -pub mod features; -pub mod maps; -pub mod project; -pub mod run; -pub mod server; -pub mod settings; -pub mod splice_array; -pub mod workflow; +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, @@ -34,5 +34,4 @@ pub use server::{ SlackIntegrationLayer, TeamsIntegrationLayer, }; pub(crate) use settings::SettingsLayer; -pub(crate) use splice_array::{SPLICE_MARKER, SpliceArray}; pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs index 006efe448..93deb953c 100644 --- a/lib/crates/fabro-config/src/layers/run.rs +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -7,7 +7,6 @@ use fabro_types::settings::run::{ WorktreeMode, }; use fabro_types::settings::{Duration, InterpString, ModelRef, Size}; -use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; use super::maps::{MergeMap, ReplaceMap, StickyMap}; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 3bfa0f54e..b1c43e559 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -21,6 +21,8 @@ pub mod project; pub mod resolve; pub mod run; pub mod storage; +#[cfg(test)] +mod tests; pub mod user; use std::path::Path; @@ -48,11 +50,12 @@ pub use layers::{ ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, TeamsIntegrationLayer, WorkflowLayer, }; -pub(crate) use layers::{Combine, SPLICE_MARKER, SettingsLayer, SpliceArray}; -pub use parse::{ParseError, parse_settings_layer}; +pub(crate) use layers::{Combine, SettingsLayer}; +pub use parse::ParseError; +pub(crate) use parse::parse_settings_layer; pub use resolve::{ - ResolveError, dev_token_auth_enabled, resolve_cli, resolve_features, resolve_project, - resolve_run, resolve_server, resolve_workflow, + 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/parse.rs b/lib/crates/fabro-config/src/parse.rs index afa67ba7f..f3fb77232 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -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_layer(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)?; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index f6f888a3a..72c96f653 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -16,7 +16,7 @@ use fabro_types::settings::RunNamespace; use serde::Serialize; use crate::load::load_settings_path; -use crate::{Error, Result, RunGoalLayer, SettingsLayer, WorkflowSettingsBuilder, run}; +use crate::{Error, Result, SettingsLayer, WorkflowSettingsBuilder, run}; const CONFIG_FILENAME: &str = ".fabro/project.toml"; #[derive(Clone, Debug)] @@ -108,7 +108,7 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result PathBuf { +pub(crate) fn resolve_working_directory(settings: &SettingsLayer, caller_cwd: &Path) -> PathBuf { let Some(run_settings) = WorkflowSettingsBuilder::run_from_layer(settings).ok() else { return caller_cwd.to_path_buf(); }; @@ -495,7 +495,7 @@ retros = true #[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"); @@ -553,7 +553,7 @@ directory = "../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"); diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 8a4eba96d..19915279b 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -12,7 +12,8 @@ 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(crate) use server::dev_token_auth_enabled; +pub use server::resolve_server; pub use workflow::resolve_workflow; pub(crate) fn require_interp( diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index dbbe31dbb..891d575a1 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -14,12 +14,12 @@ use super::{ResolveError, default_interp, parse_socket_addr, require_interp}; use crate::user::default_storage_dir; use crate::{ IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, - ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, - ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, - ServerLoggingLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, + ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, + ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer, + ServerStorageLayer, ServerWebLayer, SettingsLayer, }; -pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { +pub(crate) fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { layer .server .as_ref() diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 17b75a881..bb512d943 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -21,7 +21,7 @@ use crate::{Result, RunGoalLayer, RunLayer, SettingsLayer}; /// /// 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) } @@ -68,7 +68,7 @@ impl std::error::Error for ResolveRunGoalError { } } -pub fn resolve_run_goal( +pub(crate) fn resolve_run_goal( settings: &SettingsLayer, base_dir: &Path, ) -> std::result::Result, ResolveRunGoalError> { @@ -147,9 +147,10 @@ fn resolve_goal( #[cfg(test)] mod tests { - use fabro_types::settings::run::{RunGoal, RunGoalLayer}; + use fabro_types::settings::run::RunGoal; use super::*; + use crate::RunGoalLayer; #[test] fn load_run_config_rewrites_relative_goal_file_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..30196e172 100644 --- a/lib/crates/fabro-config/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -1,9 +1,10 @@ +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") + crate::parse_settings_layer(input).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 94% rename from lib/crates/fabro-config/tests/defaults.rs rename to lib/crates/fabro-config/src/tests/defaults.rs index 4e2278790..774452ae7 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -1,15 +1,17 @@ -use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder, parse_settings_layer}; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; use fabro_types::settings::server::ObjectStoreProvider; -use fabro_types::settings::{Combine, SettingsLayer}; + +use crate::{ + Combine, ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder, parse_settings_layer, +}; fn parse(source: &str) -> SettingsLayer { parse_settings_layer(source).expect("fixture should parse") } fn embedded_defaults() -> SettingsLayer { - parse(include_str!("../src/defaults.toml")) + parse(include_str!("../defaults.toml")) } #[test] 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 97% rename from lib/crates/fabro-config/tests/resolve_cli.rs rename to lib/crates/fabro-config/src/tests/resolve_cli.rs index e3edfa55f..76a8277b4 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/src/tests/resolve_cli.rs @@ -3,12 +3,13 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::UserSettingsBuilder; +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(); diff --git a/lib/crates/fabro-config/tests/resolve_features.rs b/lib/crates/fabro-config/src/tests/resolve_features.rs similarity index 87% rename from lib/crates/fabro-config/tests/resolve_features.rs rename to lib/crates/fabro-config/src/tests/resolve_features.rs index 00c131356..ce197d12f 100644 --- a/lib/crates/fabro-config/tests/resolve_features.rs +++ b/lib/crates/fabro-config/src/tests/resolve_features.rs @@ -1,5 +1,4 @@ -use fabro_config::UserSettingsBuilder; -use fabro_types::settings::SettingsLayer; +use crate::{SettingsLayer, UserSettingsBuilder}; #[test] fn resolves_features_defaults_from_empty_settings() { diff --git a/lib/crates/fabro-config/tests/resolve_project.rs b/lib/crates/fabro-config/src/tests/resolve_project.rs similarity index 92% rename from lib/crates/fabro-config/tests/resolve_project.rs rename to lib/crates/fabro-config/src/tests/resolve_project.rs index 202bf74df..21c62d00c 100644 --- a/lib/crates/fabro-config/tests/resolve_project.rs +++ b/lib/crates/fabro-config/src/tests/resolve_project.rs @@ -1,5 +1,4 @@ -use fabro_config::WorkflowSettingsBuilder; -use fabro_types::settings::SettingsLayer; +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_project_defaults_from_empty_settings() { diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/src/tests/resolve_root.rs similarity index 97% rename from lib/crates/fabro-config/tests/resolve_root.rs rename to lib/crates/fabro-config/src/tests/resolve_root.rs index 81a06c92e..1d75d6221 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/src/tests/resolve_root.rs @@ -1,6 +1,7 @@ -use fabro_config::{ServerSettingsBuilder, WorkflowSettingsBuilder}; +use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; -use fabro_types::settings::{InterpString, SettingsLayer}; + +use crate::{ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_root_settings_require_explicit_server_auth_methods() { diff --git a/lib/crates/fabro-config/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs similarity index 93% rename from lib/crates/fabro-config/tests/resolve_run.rs rename to lib/crates/fabro-config/src/tests/resolve_run.rs index 414eb0491..ac29ba325 100644 --- a/lib/crates/fabro-config/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -1,6 +1,7 @@ -use fabro_config::WorkflowSettingsBuilder; +use fabro_types::settings::InterpString; use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode}; -use fabro_types::settings::{InterpString, SettingsLayer}; + +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_run_defaults_from_empty_settings() { diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs similarity index 96% rename from lib/crates/fabro-config/tests/resolve_server.rs rename to lib/crates/fabro-config/src/tests/resolve_server.rs index 3a85af688..ec9c6755b 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -3,15 +3,17 @@ reason = "sync test fixture setup; not on a Tokio path" )] -use fabro_config::user::default_storage_dir; -use fabro_config::{ServerSettingsBuilder, parse_settings_layer}; +use fabro_types::settings::InterpString; use fabro_types::settings::server::{ GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings, }; -use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_util::Home; use temp_env::with_var; +use crate::resolve::dev_token_auth_enabled; +use crate::user::default_storage_dir; +use crate::{ServerSettingsBuilder, SettingsLayer, parse_settings_layer}; + fn parse(source: &str) -> SettingsLayer { let mut layer = parse_settings_layer(source).expect("fixture should parse"); layer.ensure_test_auth_methods(); @@ -152,7 +154,7 @@ session_sandboxes = true #[test] fn parsing_rejects_inbound_listener_tls_configuration() { - let err = fabro_config::parse_settings_layer( + let err = parse_settings_layer( r#" _version = 1 @@ -575,10 +577,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 92% rename from lib/crates/fabro-config/tests/resolve_workflow.rs rename to lib/crates/fabro-config/src/tests/resolve_workflow.rs index 37540632d..3e8eb3c9f 100644 --- a/lib/crates/fabro-config/tests/resolve_workflow.rs +++ b/lib/crates/fabro-config/src/tests/resolve_workflow.rs @@ -1,5 +1,4 @@ -use fabro_config::WorkflowSettingsBuilder; -use fabro_types::settings::SettingsLayer; +use crate::{SettingsLayer, WorkflowSettingsBuilder}; #[test] fn resolves_workflow_defaults_from_empty_settings() { diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 4fb024d5f..49f36a0f4 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -41,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)) From 1bd7b7688f8bddd3e5f6d47c5111380f5eda8cb5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:10:45 -0400 Subject: [PATCH 51/60] lock down sparse settings exports in fabro-types --- Cargo.lock | 1 + lib/crates/fabro-checkpoint/Cargo.toml | 1 + lib/crates/fabro-checkpoint/src/author.rs | 3 +- lib/crates/fabro-config/src/resolve/mod.rs | 1 + lib/crates/fabro-types/src/lib.rs | 1 - lib/crates/fabro-types/src/settings/cli.rs | 18 +++--- .../fabro-types/src/settings/combine.rs | 15 ++++- .../fabro-types/src/settings/features.rs | 2 +- lib/crates/fabro-types/src/settings/layer.rs | 6 +- lib/crates/fabro-types/src/settings/maps.rs | 21 +++++-- lib/crates/fabro-types/src/settings/mod.rs | 31 +++++----- .../fabro-types/src/settings/project.rs | 2 +- lib/crates/fabro-types/src/settings/run.rs | 56 +++++++++---------- lib/crates/fabro-types/src/settings/server.rs | 42 +++++++------- .../fabro-types/src/settings/splice_array.rs | 18 +++--- .../fabro-types/src/settings/workflow.rs | 2 +- .../tests/server_settings_serde.rs | 23 -------- 17 files changed, 118 insertions(+), 125 deletions(-) delete mode 100644 lib/crates/fabro-types/tests/server_settings_serde.rs diff --git a/Cargo.lock b/Cargo.lock index e123d0e23..074221793 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", 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-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 19915279b..d6f69a1f0 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -12,6 +12,7 @@ use fabro_types::settings::InterpString; pub use features::resolve_features; pub use project::resolve_project; pub use run::resolve_run; +#[cfg(test)] pub(crate) use server::dev_token_auth_enabled; pub use server::resolve_server; pub use workflow::resolve_workflow; diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 15a787534..c10d44c55 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -62,7 +62,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/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index c3130f510..65f3055a9 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -74,7 +74,7 @@ pub struct CliLoggingSettings { /// A sparse `[cli]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliLayer { +pub(crate) struct CliLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -92,7 +92,7 @@ pub struct CliLayer { /// `[cli.target]` — explicit transport selection. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] -pub enum CliTargetLayer { +pub(crate) enum CliTargetLayer { Http { #[serde(default)] url: Option, @@ -106,7 +106,7 @@ pub enum CliTargetLayer { /// `[cli.auth]` — explicit auth strategy selection. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliAuthLayer { +pub(crate) struct CliAuthLayer { /// `none` explicitly disables inherited auth. #[serde(default, skip_serializing_if = "Option::is_none")] pub strategy: Option, @@ -122,7 +122,7 @@ pub enum CliAuthStrategy { /// `[cli.exec]` — `fabro exec` defaults. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliExecLayer { +pub(crate) 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, @@ -134,7 +134,7 @@ pub struct CliExecLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliExecModelLayer { +pub(crate) struct CliExecModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -143,7 +143,7 @@ pub struct CliExecModelLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliExecAgentLayer { +pub(crate) struct CliExecAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option, /// Agent-scoped MCP entries for `fabro exec`. @@ -154,7 +154,7 @@ pub struct CliExecAgentLayer { /// `[cli.output]` — generic CLI output defaults. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliOutputLayer { +pub(crate) struct CliOutputLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -181,7 +181,7 @@ pub enum OutputVerbosity { /// `[cli.updates]` — upgrade check toggle. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliUpdatesLayer { +pub(crate) struct CliUpdatesLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub check: Option, } @@ -189,7 +189,7 @@ pub struct CliUpdatesLayer { /// `[cli.logging]` — process-owned logging configuration for the CLI. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliLoggingLayer { +pub(crate) 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-types/src/settings/combine.rs index 985f14e66..9a781d955 100644 --- a/lib/crates/fabro-types/src/settings/combine.rs +++ b/lib/crates/fabro-types/src/settings/combine.rs @@ -19,7 +19,7 @@ use super::server::{ }; use super::size::Size; -pub trait Combine { +pub(crate) trait Combine { /// Combine two values, preferring the values in `self`. #[must_use] fn combine(self, other: Self) -> Self; @@ -137,7 +137,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 { +pub(crate) trait SpliceMarker { fn is_splice(&self) -> bool; } @@ -217,12 +217,21 @@ fn combine_hooks(fallback: &[HookEntry], current: Vec) -> Vec, b: Option, } + impl Combine for FieldMergeLayer { + fn combine(self, other: Self) -> Self { + Self { + a: self.a.combine(other.a), + b: self.b.combine(other.b), + } + } + } + #[derive(Debug, PartialEq)] struct WholeReplaceLayer { a: Option, diff --git a/lib/crates/fabro-types/src/settings/features.rs b/lib/crates/fabro-types/src/settings/features.rs index 79dfc9f59..29df5e09c 100644 --- a/lib/crates/fabro-types/src/settings/features.rs +++ b/lib/crates/fabro-types/src/settings/features.rs @@ -17,7 +17,7 @@ pub struct FeaturesNamespace { /// 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 { +pub(crate) struct FeaturesLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub session_sandboxes: Option, } diff --git a/lib/crates/fabro-types/src/settings/layer.rs b/lib/crates/fabro-types/src/settings/layer.rs index 188ece46d..4a5146fc9 100644 --- a/lib/crates/fabro-types/src/settings/layer.rs +++ b/lib/crates/fabro-types/src/settings/layer.rs @@ -16,7 +16,7 @@ use super::workflow::WorkflowLayer; /// A sparse settings layer before merge/resolve. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -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")] @@ -39,7 +39,7 @@ impl SettingsLayer { /// 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,7 +48,7 @@ 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) { + pub(crate) fn ensure_test_auth_methods(&mut self) { use super::server::{ServerAuthLayer, ServerAuthMethod, ServerLayer as ServerLayerTy}; if self diff --git a/lib/crates/fabro-types/src/settings/maps.rs b/lib/crates/fabro-types/src/settings/maps.rs index f34bdb6a7..21db83484 100644 --- a/lib/crates/fabro-types/src/settings/maps.rs +++ b/lib/crates/fabro-types/src/settings/maps.rs @@ -8,26 +8,26 @@ use super::combine::Combine; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] -pub struct ReplaceMap(pub HashMap); +pub(crate) struct ReplaceMap(pub HashMap); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] -pub struct StickyMap(pub HashMap); +pub(crate) struct StickyMap(pub HashMap); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] -pub struct MergeMap(pub HashMap); +pub(crate) struct MergeMap(pub HashMap); macro_rules! impl_map_wrapper { ($name:ident) => { impl $name { #[must_use] - pub fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.0.is_empty() } #[must_use] - pub fn into_inner(self) -> HashMap { + pub(crate) fn into_inner(self) -> HashMap { self.0 } } @@ -107,12 +107,21 @@ impl Combine for MergeMap { mod tests { use super::*; - #[derive(Debug, PartialEq, fabro_macros::Combine)] + #[derive(Debug, PartialEq)] struct ValueLayer { a: Option, b: Option, } + impl Combine for ValueLayer { + fn combine(self, other: Self) -> Self { + Self { + a: self.a.combine(other.a), + b: self.b.combine(other.b), + } + } + } + #[test] fn replace_map_self_wins_when_non_empty() { let this = ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])); diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 95831e6cd..372f88971 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -10,51 +10,46 @@ //! exists. pub mod cli; -pub mod combine; +mod combine; pub mod duration; pub mod features; pub mod interp; -pub mod layer; -pub mod maps; +mod layer; +mod maps; pub mod model_ref; pub mod project; pub mod run; pub mod server; pub mod size; -pub mod splice_array; +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 eb1a439be..470076b8e 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -21,7 +21,7 @@ pub struct ProjectNamespace { /// A sparse `[project]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ProjectLayer { +pub(crate) struct ProjectLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index d0d0a5b01..a1033eb15 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -429,7 +429,7 @@ pub struct ArtifactsSettings { /// A sparse `[run]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunLayer { +pub(crate) struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub goal: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -491,7 +491,7 @@ pub struct RunLayer { /// effective working directory. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum RunGoalLayer { +pub(crate) enum RunGoalLayer { Inline(InterpString), File { file: InterpString }, } @@ -520,7 +520,7 @@ pub enum ResolvedGoalSource { /// `[run.model]` — provider-neutral default model selection. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunModelLayer { +pub(crate) struct RunModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -533,7 +533,7 @@ pub struct RunModelLayer { /// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ModelRefOrSplice { +pub(crate) enum ModelRefOrSplice { ModelRef(ModelRef), Splice, } @@ -562,14 +562,14 @@ impl<'de> Deserialize<'de> for ModelRefOrSplice { /// `[run.git]` — local git behavior such as commit author. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunGitLayer { +pub(crate) struct RunGitLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct GitAuthorLayer { +pub(crate) struct GitAuthorLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -580,7 +580,7 @@ pub struct GitAuthorLayer { /// across layers. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunPrepareLayer { +pub(crate) struct RunPrepareLayer { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub steps: Vec, /// Optional timeout applied to each prepare step. @@ -603,7 +603,7 @@ pub struct PrepareStep { /// `[run.execution]` — run posture knobs. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunExecutionLayer { +pub(crate) struct RunExecutionLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -630,7 +630,7 @@ pub enum ApprovalMode { /// `[run.checkpoint]` — checkpoint policy. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunCheckpointLayer { +pub(crate) struct RunCheckpointLayer { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub exclude_globs: Vec, } @@ -638,7 +638,7 @@ pub struct RunCheckpointLayer { /// `[run.sandbox]` — sandbox selection and execution-environment surface. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunSandboxLayer { +pub(crate) struct RunSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -656,7 +656,7 @@ pub struct RunSandboxLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct LocalSandboxLayer { +pub(crate) struct LocalSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree_mode: Option, } @@ -673,7 +673,7 @@ pub enum WorktreeMode { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct DaytonaSandboxLayer { +pub(crate) struct DaytonaSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_stop_interval: Option, /// Sticky merge-by-key (provider-native labels). @@ -689,7 +689,7 @@ pub struct DaytonaSandboxLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct DaytonaSnapshotLayer { +pub(crate) struct DaytonaSnapshotLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -704,7 +704,7 @@ pub struct DaytonaSnapshotLayer { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum DaytonaDockerfileLayer { +pub(crate) enum DaytonaDockerfileLayer { Inline(String), Path { path: String }, } @@ -720,7 +720,7 @@ pub enum DaytonaNetworkLayer { /// `[run.notifications.]` — a keyed notification route. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct NotificationRouteLayer { +pub(crate) struct NotificationRouteLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -739,7 +739,7 @@ pub struct NotificationRouteLayer { /// A single string array entry that may be the splice marker. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StringOrSplice { +pub(crate) enum StringOrSplice { Value(String), Splice, } @@ -767,7 +767,7 @@ impl<'de> Deserialize<'de> for StringOrSplice { /// Provider-specific destination fields for a notification route. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct NotificationProviderLayer { +pub(crate) struct NotificationProviderLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub channel: Option, } @@ -775,7 +775,7 @@ pub struct NotificationProviderLayer { /// `[run.interviews]` — external interview delivery. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct InterviewsLayer { +pub(crate) struct InterviewsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -788,7 +788,7 @@ pub struct InterviewsLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct InterviewProviderLayer { +pub(crate) struct InterviewProviderLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub channel: Option, } @@ -796,7 +796,7 @@ pub struct InterviewProviderLayer { /// `[run.agent]` — agent knobs only (permissions, MCPs). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunAgentLayer { +pub(crate) struct RunAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option, /// Agent-scoped MCP server entries, keyed by name. @@ -817,7 +817,7 @@ pub enum AgentPermissions { /// transports use neither field. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] -pub enum McpEntryLayer { +pub(crate) enum McpEntryLayer { Http { #[serde(default)] enabled: Option, @@ -865,7 +865,7 @@ pub enum McpEntryLayer { /// used for cross-layer replace-by-id merging. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct HookEntry { +pub(crate) struct HookEntry { /// Optional merge identity. Hooks with the same `id` replace in place. #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, @@ -906,7 +906,7 @@ pub struct HookEntry { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum HookTlsMode { +pub(crate) enum HookTlsMode { #[default] Verify, NoVerify, @@ -918,7 +918,7 @@ pub enum HookTlsMode { /// struct without a discriminator. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum HookAgentMarker { +pub(crate) enum HookAgentMarker { #[default] Enabled, } @@ -947,7 +947,7 @@ pub enum HookEvent { /// `[run.scm]` — remote SCM host/provider behavior. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunScmLayer { +pub(crate) struct RunScmLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -964,12 +964,12 @@ pub struct RunScmLayer { /// `run.pull_request` until a concrete use case lands. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ScmGitHubLayer; +pub(crate) struct ScmGitHubLayer; /// `[run.pull_request]` — provider-neutral PR behavior. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunPullRequestLayer { +pub(crate) struct RunPullRequestLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -991,7 +991,7 @@ pub enum MergeStrategy { /// `[run.artifacts]` — run artifact collection policy. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct RunArtifactsLayer { +pub(crate) 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 bdce8d470..9e49f0486 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -309,7 +309,7 @@ where /// A sparse `[server]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerLayer { +pub(crate) struct ServerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub listen: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -337,7 +337,7 @@ pub struct ServerLayer { /// `[server.listen]` — shared bind transport. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] -pub enum ServerListenLayer { +pub(crate) enum ServerListenLayer { Tcp { #[serde(default)] address: Option, @@ -353,7 +353,7 @@ pub enum ServerListenLayer { /// `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 { +pub(crate) struct ServerApiLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, } @@ -361,7 +361,7 @@ pub struct ServerApiLayer { /// `[server.web]` — web surface settings. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerWebLayer { +pub(crate) struct ServerWebLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -375,7 +375,7 @@ pub struct ServerWebLayer { /// explicitly opt in to insecure configurations. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerAuthLayer { +pub(crate) struct ServerAuthLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub methods: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -384,14 +384,14 @@ pub struct ServerAuthLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerAuthGithubLayer { +pub(crate) struct ServerAuthGithubLayer { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allowed_usernames: Vec, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerIpAllowlistLayer { +pub(crate) struct ServerIpAllowlistLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub entries: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -400,7 +400,7 @@ pub struct ServerIpAllowlistLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerIpAllowlistOverrideLayer { +pub(crate) struct ServerIpAllowlistOverrideLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub entries: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -410,7 +410,7 @@ pub struct ServerIpAllowlistOverrideLayer { /// `[server.storage]` — single managed local disk root. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerStorageLayer { +pub(crate) struct ServerStorageLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub root: Option, } @@ -418,7 +418,7 @@ pub struct ServerStorageLayer { /// `[server.artifacts]` — object-store-backed artifact storage. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerArtifactsLayer { +pub(crate) struct ServerArtifactsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -432,7 +432,7 @@ pub struct ServerArtifactsLayer { /// `[server.slatedb]` — SlateDB bottomless storage plus tunables. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerSlateDbLayer { +pub(crate) struct ServerSlateDbLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -458,7 +458,7 @@ pub enum ObjectStoreProvider { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ObjectStoreLocalLayer { +pub(crate) struct ObjectStoreLocalLayer { /// Overrides the default root, which otherwise falls back to /// `{server.storage.root}/objects/{domain}`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -467,7 +467,7 @@ pub struct ObjectStoreLocalLayer { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ObjectStoreS3Layer { +pub(crate) struct ObjectStoreS3Layer { #[serde(default, skip_serializing_if = "Option::is_none")] pub bucket: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -481,7 +481,7 @@ pub struct ObjectStoreS3Layer { /// `[server.scheduler]` — server-managed execution policy. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerSchedulerLayer { +pub(crate) struct ServerSchedulerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrent_runs: Option, } @@ -489,7 +489,7 @@ pub struct ServerSchedulerLayer { /// `[server.logging]` — process-owned logging configuration for the server. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerLoggingLayer { +pub(crate) struct ServerLoggingLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub level: Option, } @@ -500,7 +500,7 @@ pub struct ServerLoggingLayer { /// shape so strict unknown-field validation still holds. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerIntegrationsLayer { +pub(crate) struct ServerIntegrationsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -515,7 +515,7 @@ pub struct ServerIntegrationsLayer { /// webhooks. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct GithubIntegrationLayer { +pub(crate) struct GithubIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -535,7 +535,7 @@ pub struct GithubIntegrationLayer { /// `[server.integrations.slack]` — Slack workspace credentials and defaults. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct SlackIntegrationLayer { +pub(crate) struct SlackIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -545,7 +545,7 @@ pub struct SlackIntegrationLayer { /// `[server.integrations.discord]` — Discord workspace configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct DiscordIntegrationLayer { +pub(crate) struct DiscordIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, } @@ -553,14 +553,14 @@ pub struct DiscordIntegrationLayer { /// `[server.integrations.teams]` — Microsoft Teams configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct TeamsIntegrationLayer { +pub(crate) struct TeamsIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct IntegrationWebhooksLayer { +pub(crate) struct IntegrationWebhooksLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub strategy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/splice_array.rs b/lib/crates/fabro-types/src/settings/splice_array.rs index 35d312168..ed435be5e 100644 --- a/lib/crates/fabro-types/src/settings/splice_array.rs +++ b/lib/crates/fabro-types/src/settings/splice_array.rs @@ -14,11 +14,11 @@ 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 = "..."; +pub(crate) const SPLICE_MARKER: &str = "..."; /// A string array that may contain at most one splice marker. #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct SpliceArray { +pub(crate) struct SpliceArray { entries: Vec, } @@ -30,7 +30,7 @@ enum Entry { /// An error returned when a splice array fails validation. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum SpliceArrayError { +pub(crate) enum SpliceArrayError { /// The array contained more than one splice marker. MultipleMarkers, } @@ -49,7 +49,7 @@ impl std::error::Error for SpliceArrayError {} impl SpliceArray { /// Build a splice array from a raw `Vec`. - pub fn from_raw(raw: Vec) -> Result { + pub(crate) fn from_raw(raw: Vec) -> Result { let mut entries = Vec::with_capacity(raw.len()); let mut marker_count = 0; for item in raw { @@ -68,7 +68,7 @@ impl SpliceArray { /// Build a splice array with no inherited splice marker. #[must_use] - pub fn from_values(values: impl IntoIterator) -> Self { + pub(crate) fn from_values(values: impl IntoIterator) -> Self { Self { entries: values.into_iter().map(Entry::Value).collect(), } @@ -76,19 +76,19 @@ impl SpliceArray { /// True when the array contains a splice marker. #[must_use] - pub fn has_splice(&self) -> bool { + pub(crate) 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 { + pub(crate) 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> { + pub(crate) fn values(&self) -> Vec<&str> { self.entries .iter() .filter_map(|e| match e { @@ -105,7 +105,7 @@ impl SpliceArray { /// - If the array has no splice marker, it replaces the inherited list /// wholesale. #[must_use] - pub fn resolve(self, inherited: Vec) -> Vec { + pub(crate) fn resolve(self, inherited: Vec) -> Vec { let Some(pos) = self.splice_position() else { return self .entries diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index 834ec58ed..4f356d6c3 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -21,7 +21,7 @@ pub struct WorkflowNamespace { /// A sparse `[workflow]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct WorkflowLayer { +pub(crate) struct WorkflowLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] 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); -} From e17bd789ddd8a540a9e8c168cce297f0957ae5b0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:15:39 -0400 Subject: [PATCH 52/60] drop dead fabro-types settings layer module --- lib/crates/fabro-types/src/settings/layer.rs | 67 ------------------- lib/crates/fabro-types/src/settings/mod.rs | 1 - .../fabro-workflow/tests/it/integration.rs | 6 +- 3 files changed, 3 insertions(+), 71 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/layer.rs diff --git a/lib/crates/fabro-types/src/settings/layer.rs b/lib/crates/fabro-types/src/settings/layer.rs deleted file mode 100644 index 4a5146fc9..000000000 --- a/lib/crates/fabro-types/src/settings/layer.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! The top-level sparse settings layer. -//! -//! This struct models a single settings file (`~/.fabro/settings.toml`, -//! `.fabro/project.toml`, or `workflow.toml`) after deserialization. Fields -//! unset in the source stay `None`/empty and are layered later by -//! `fabro-config`. - -use serde::{Deserialize, Serialize}; - -use super::cli::CliLayer; -use super::features::FeaturesLayer; -use super::project::ProjectLayer; -use super::run::RunLayer; -use super::server::ServerLayer; -use super::workflow::WorkflowLayer; - -/// A sparse settings layer before merge/resolve. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -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")] - pub project: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub run: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cli: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, -} - -#[cfg(any(test, feature = "test-support"))] -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(crate) fn test_default() -> Self { - let mut layer = Self::default(); - layer.ensure_test_auth_methods(); - layer - } - - /// 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(crate) fn ensure_test_auth_methods(&mut self) { - use super::server::{ServerAuthLayer, ServerAuthMethod, ServerLayer as ServerLayerTy}; - - if self - .server - .as_ref() - .and_then(|server| server.auth.as_ref()) - .and_then(|auth| auth.methods.as_ref()) - .is_some() - { - return; - } - let server = self.server.get_or_insert_with(ServerLayerTy::default); - let auth = server.auth.get_or_insert_with(ServerAuthLayer::default); - auth.methods = Some(vec![ServerAuthMethod::DevToken]); - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 372f88971..99da38e81 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -14,7 +14,6 @@ mod combine; pub mod duration; pub mod features; pub mod interp; -mod layer; mod maps; pub mod model_ref; pub mod project; diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 0d5cc2c01..d21ae0a95 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -8117,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 --- From 941c6e83f940de365e188e6ca5d3bdf26f2c4fcf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:20:02 -0400 Subject: [PATCH 53/60] route fabro-config parsing through settings fromstr --- lib/crates/fabro-config/src/builders.rs | 28 ++++++++++++------- lib/crates/fabro-config/src/defaults.rs | 5 ++-- .../fabro-config/src/layers/settings.rs | 10 ++++--- lib/crates/fabro-config/src/lib.rs | 1 - lib/crates/fabro-config/src/load.rs | 4 +-- lib/crates/fabro-config/src/parse.rs | 12 ++++---- lib/crates/fabro-config/src/project.rs | 14 +++++----- lib/crates/fabro-config/src/resolve/mod.rs | 9 +++--- lib/crates/fabro-config/src/tests/combine.rs | 2 +- lib/crates/fabro-config/src/tests/defaults.rs | 6 ++-- .../fabro-config/src/tests/resolve_server.rs | 27 +++++++++--------- 11 files changed, 62 insertions(+), 56 deletions(-) diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index b91655dbe..ed714a5aa 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -6,7 +6,6 @@ use fabro_types::{ServerSettings, UserSettings, WorkflowSettings}; use crate::defaults::DEFAULTS_LAYER; use crate::load::load_settings_path; -use crate::parse::parse_settings_layer; use crate::resolve::{ ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server, resolve_workflow, @@ -73,7 +72,8 @@ impl ServerSettingsBuilder { } pub fn from_toml(source: &str) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Self::from_layer(&layer) } @@ -115,13 +115,15 @@ impl UserSettingsBuilder { } pub fn from_toml(source: &str) -> Result { - let layer = parse_settings_layer(source) + 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 = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Self::from_layer_with_cli_overrides(&layer, cli) } @@ -166,7 +168,8 @@ impl RunSettingsBuilder { } pub fn from_toml(source: &str) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Self::from_layer(&layer) } @@ -211,7 +214,8 @@ pub fn server_runtime_settings_from_toml( run_overrides: Option, server_overrides: Option, ) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; resolve_server_runtime_settings(layer, run_overrides, server_overrides) } @@ -261,7 +265,8 @@ impl WorkflowSettingsBuilder { } pub fn from_toml(source: &str) -> Result { - let layer = parse_settings_layer(source) + 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())) @@ -288,7 +293,8 @@ impl WorkflowSettingsBuilder { } pub fn workflow_toml(self, source: &str) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Ok(self.workflow_layer(layer)) } @@ -304,7 +310,8 @@ impl WorkflowSettingsBuilder { } pub fn project_toml(self, source: &str) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Ok(self.project_layer(layer)) } @@ -320,7 +327,8 @@ impl WorkflowSettingsBuilder { } pub fn user_toml(self, source: &str) -> Result { - let layer = parse_settings_layer(source) + let layer = source + .parse::() .map_err(|err| Error::parse("Failed to parse settings file", err))?; Ok(self.user_layer(layer)) } diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs index d4684211a..22358d3c1 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -1,8 +1,9 @@ use std::sync::LazyLock; -use crate::{SettingsLayer, parse_settings_layer}; +use crate::SettingsLayer; pub(crate) static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { - parse_settings_layer(include_str!("defaults.toml")) + include_str!("defaults.toml") + .parse::() .expect("embedded defaults.toml must parse as a valid SettingsLayer") }); diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs index e665de65e..bc6204cdd 100644 --- a/lib/crates/fabro-config/src/layers/settings.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -9,6 +9,8 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; +use crate::parse::{ParseError, parse_settings}; + use super::cli::CliLayer; use super::features::FeaturesLayer; use super::project::ProjectLayer; @@ -36,10 +38,10 @@ pub(crate) struct SettingsLayer { } impl FromStr for SettingsLayer { - type Err = toml::de::Error; + type Err = ParseError; fn from_str(source: &str) -> Result { - toml::from_str(source) + parse_settings(source) } } @@ -103,7 +105,7 @@ impl SettingsLayer { /// 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 @@ -112,7 +114,7 @@ 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) { + pub(crate) fn ensure_test_auth_methods(&mut self) { use fabro_types::settings::ServerAuthMethod; use super::server::{ServerAuthLayer, ServerLayer as ServerLayerTy}; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index b1c43e559..c02ffa4f7 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -52,7 +52,6 @@ pub use layers::{ }; pub(crate) use layers::{Combine, SettingsLayer}; pub use parse::ParseError; -pub(crate) use parse::parse_settings_layer; pub use resolve::{ ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server, resolve_workflow, diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs index 1e0f10c5a..5a2254603 100644 --- a/lib/crates/fabro-config/src/load.rs +++ b/lib/crates/fabro-config/src/load.rs @@ -7,12 +7,12 @@ use std::path::{Path, PathBuf}; use fabro_types::settings::InterpString; -use crate::parse::parse_settings_layer; use crate::{Error, Result, RunGoalLayer, SettingsLayer}; 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); diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index f3fb77232..50d842b31 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -58,7 +58,7 @@ impl fmt::Display for VersionError { impl std::error::Error for VersionError {} -pub(crate) 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 72c96f653..bf8cfca31 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -404,7 +404,7 @@ mod tests { #[test] fn parse_minimal_config() { - let config = crate::parse_settings_layer("_version = 1\n").unwrap(); + let config = "_version = 1\n".parse::().unwrap(); assert_eq!(config.version, Some(1)); assert!(config.project.is_none()); } @@ -429,14 +429,13 @@ directory = "custom/" #[test] fn parse_with_run_execution_retros() { - let config = crate::parse_settings_layer( - " + let config = " _version = 1 [run.execution] retros = true -", - ) +" + .parse::() .unwrap(); assert_eq!( config @@ -450,7 +449,8 @@ retros = true #[test] fn parse_rejects_legacy_llm_section() { - let err = crate::parse_settings_layer("_version = 1\n[llm]\nprovider = \"openai\"\n") + let err = "_version = 1\n[llm]\nprovider = \"openai\"\n" + .parse::() .unwrap_err(); let text = format!("{err:#}"); assert!( @@ -461,7 +461,7 @@ retros = true #[test] fn parse_higher_version_errors() { - let err = crate::parse_settings_layer("_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"), diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index d6f69a1f0..88edbd070 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -58,12 +58,11 @@ mod tests { use fabro_types::settings::run::{HookType, McpTransport, TlsMode}; - use crate::{WorkflowSettingsBuilder, 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] @@ -98,8 +97,8 @@ url = "https://hooks.example.com" [run.hooks.headers] Authorization = "Bearer {{ env.HOOK_TOKEN }}" -"#, - ) +"# + .parse::() .expect("settings fixture should parse"); let resolved = WorkflowSettingsBuilder::from_layer(&settings) diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs index 30196e172..16470d06c 100644 --- a/lib/crates/fabro-config/src/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -4,7 +4,7 @@ use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use crate::{Combine, SettingsLayer, StringOrSplice}; fn parse(input: &str) -> SettingsLayer { - crate::parse_settings_layer(input).expect("fixture should parse") + input.parse::().expect("fixture should parse") } #[test] diff --git a/lib/crates/fabro-config/src/tests/defaults.rs b/lib/crates/fabro-config/src/tests/defaults.rs index 774452ae7..51044b4fa 100644 --- a/lib/crates/fabro-config/src/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -2,12 +2,10 @@ 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, parse_settings_layer, -}; +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 { diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs index ec9c6755b..d545786ac 100644 --- a/lib/crates/fabro-config/src/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -12,10 +12,10 @@ use temp_env::with_var; use crate::resolve::dev_token_auth_enabled; use crate::user::default_storage_dir; -use crate::{ServerSettingsBuilder, SettingsLayer, parse_settings_layer}; +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 } @@ -37,7 +37,7 @@ fn resolve_errors(error: fabro_config::Error) -> Vec } } -fn render_resolve_errors(error: fabro_config::Error) -> String { +fn render_resolve_error_lines(error: fabro_config::Error) -> String { resolve_errors(error) .into_iter() .map(|error| error.to_string()) @@ -154,8 +154,7 @@ session_sandboxes = true #[test] fn parsing_rejects_inbound_listener_tls_configuration() { - let err = parse_settings_layer( - r#" + let err = r#" _version = 1 [server.listen] @@ -164,8 +163,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`")); @@ -185,7 +184,7 @@ endpoint = "{{ env.S3_ENDPOINT }}" "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file) .expect_err("s3 config without bucket/region should fail"), ); @@ -391,7 +390,7 @@ strategy = "server_url" "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file) .expect_err("server_url webhook strategy should require server.api.url"), ); @@ -413,7 +412,7 @@ strategy = "tailscale_funnel" "#, ); - let rendered = render_resolve_errors(ServerSettingsBuilder::from_layer(&file).expect_err( + let rendered = render_resolve_error_lines(ServerSettingsBuilder::from_layer(&file).expect_err( "configured webhook strategy should require server.integrations.github.app_id", )); @@ -431,7 +430,7 @@ entries = ["10.0.0.0/33"] "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"), ); @@ -449,7 +448,7 @@ entries = ["github_meta_hooks"] "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file) .expect_err("github_meta_hooks should be rejected outside github webhooks"), ); @@ -472,7 +471,7 @@ entries = ["10.0.0.0/8"] "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file) .expect_err("unix allowlist without trusted proxies should fail"), ); @@ -495,7 +494,7 @@ entries = ["github_meta_hooks"] "#, ); - let rendered = render_resolve_errors( + let rendered = render_resolve_error_lines( ServerSettingsBuilder::from_layer(&file) .expect_err("unix github webhook allowlist without trusted proxies should fail"), ); From 6fc7251471574d1dd9b27c21471c06e8dd61df78 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:30:30 -0400 Subject: [PATCH 54/60] close config boundary audit and settings snapshot naming --- apps/fabro-web/app/lib/workflow-api.ts | 8 ++-- apps/fabro-web/app/routes/workflow-detail.tsx | 11 ++--- ...-refactor-settings-api-entrypoints-plan.md | 6 +-- ...types-boundary-and-dense-migration-plan.md | 40 ++++++++++--------- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 2 +- lib/crates/fabro-config/src/layers/combine.rs | 8 +++- .../fabro-config/src/layers/settings.rs | 3 +- .../fabro-config/src/layers/splice_array.rs | 24 ++++------- lib/crates/fabro-config/src/project.rs | 4 +- lib/crates/fabro-config/src/tests/combine.rs | 4 +- lib/crates/fabro-config/src/tests/defaults.rs | 4 +- .../fabro-config/src/tests/resolve_server.rs | 4 +- lib/crates/fabro-server/src/run_manifest.rs | 4 +- 13 files changed, 64 insertions(+), 58 deletions(-) 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/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/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 index c7caa09e4..072ef0de0 100644 --- 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 @@ -1,7 +1,7 @@ --- title: "refactor: fabro-config types boundary and dense-type migration" type: refactor -status: active +status: completed date: 2026-04-23 --- @@ -9,6 +9,8 @@ date: 2026-04-23 ## 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**. @@ -52,7 +54,7 @@ Greenfield app, no production deployments, single-node per memory `project_fabro - **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 sparse `RunSettingsLayer` (today) 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. +- **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 @@ -94,7 +96,7 @@ Greenfield app, no production deployments, single-node per memory `project_fabro - `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 `RunSettingsLayer` schema (line ~5448). Schema renamed to something like `RunSettings` matching the dense shape; response type changes. +- `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. @@ -152,7 +154,7 @@ None gathered. The refactor operates entirely within repo patterns and the `uv`- **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 `RunSettingsLayer` 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). +- **`/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. @@ -173,7 +175,7 @@ None gathered. The refactor operates entirely within repo patterns and the `uv`- - **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 `RunSettingsLayer` → `RunSettings`. Web UI and TypeScript client update in lockstep. +- **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 @@ -383,7 +385,7 @@ flowchart TB - 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 assertion `resolved_server.integrations.github.app_id == "snapshotted-app-id"` is deleted (it becomes vacuously unreachable — `server.*` is no longer in the materialized layer). +- 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. @@ -679,23 +681,23 @@ flowchart TB - [ ] **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 `RunSettingsLayer` to `RunSettings`. TypeScript client regenerates. Web UI route updates in lockstep so users continue to see a correct settings snapshot. +**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. `RunSettingsLayer` schema (line ~5448) is renamed to `RunSettings` or replaced with a `$ref` to a shared `WorkflowSettings`-shaped schema. Update docstring accordingly. +- 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 `RunSettingsLayer` (sparse). 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. +- 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 `RunSettingsLayer` to `RunSettings`. Update the description to reflect "the resolved `WorkflowSettings` snapshot captured at run creation." +- 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. @@ -715,7 +717,7 @@ flowchart TB - Regression: the OpenAPI diff between the old and new schema is documented in the PR description so reviewers can assess downstream impact. **Verification:** -- `rg "RunSettingsLayer" .` returns zero hits (schema rename is complete across spec, Rust client, TypeScript client, web UI). +- 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). @@ -841,7 +843,7 @@ Two relocations in one commit: - **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** (sparse `RunSettingsLayer` → dense `RunSettings`/`WorkflowSettings`). Schema renamed. TypeScript client and web UI update in lockstep per Unit 2.5. + - `/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. @@ -923,9 +925,9 @@ rg "WorkflowSettings::(builder|from_layer)\b" lib/crates/ rg "only generic CLI lifecycle surface allowed to read" lib/crates/ # Expected: zero hits -# 10. Obsolete run_manifest.rs snapshotted-app-id assertion is gone -rg "snapshotted-app-id" lib/crates/fabro-server/ -# 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/ @@ -943,9 +945,9 @@ rg "crate::settings::Combine" lib/crates/fabro-macros/ rg "fabro_config::(UserSettings|ServerSettings|WorkflowSettings)" lib/crates/ # Expected: zero hits (should be fabro_types::UserSettings, etc.) -# 15. OpenAPI RunSettingsLayer schema renamed -rg "RunSettingsLayer" . -# Expected: zero hits (renamed to RunSettings or similar) +# 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. @@ -963,7 +965,7 @@ rg "RunSettingsLayer" . - [ ] `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 `resolved_server.integrations.github.app_id == "snapshotted-app-id"` via `prepared.settings` no longer exists. +- [ ] 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)`. diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index de9d52b8e..685b0d513 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -271,7 +271,7 @@ _version = 1 methods = [\"dev-token\"] [server.integrations.github] -app_id = \"snapshotted-app-id\" +app_id = \"fixture-app-id\" ", ); context.write_temp( diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index df085549e..90f2dec14 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -21,7 +21,11 @@ use super::server::{ ServerListenLayer, ServerLoggingLayer, }; -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; @@ -139,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/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs index bc6204cdd..453ea7a35 100644 --- a/lib/crates/fabro-config/src/layers/settings.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -9,14 +9,13 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; -use crate::parse::{ParseError, parse_settings}; - use super::cli::CliLayer; use super::features::FeaturesLayer; 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)] diff --git a/lib/crates/fabro-config/src/layers/splice_array.rs b/lib/crates/fabro-config/src/layers/splice_array.rs index 35d312168..51a033907 100644 --- a/lib/crates/fabro-config/src/layers/splice_array.rs +++ b/lib/crates/fabro-config/src/layers/splice_array.rs @@ -14,11 +14,11 @@ 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 = "..."; +pub(crate) const SPLICE_MARKER: &str = "..."; /// A string array that may contain at most one splice marker. #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct SpliceArray { +pub(crate) struct SpliceArray { entries: Vec, } @@ -30,7 +30,7 @@ enum Entry { /// An error returned when a splice array fails validation. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum SpliceArrayError { +pub(crate) enum SpliceArrayError { /// The array contained more than one splice marker. MultipleMarkers, } @@ -49,7 +49,7 @@ impl std::error::Error for SpliceArrayError {} impl SpliceArray { /// Build a splice array from a raw `Vec`. - pub fn from_raw(raw: Vec) -> Result { + pub(crate) fn from_raw(raw: Vec) -> Result { let mut entries = Vec::with_capacity(raw.len()); let mut marker_count = 0; for item in raw { @@ -66,29 +66,21 @@ impl SpliceArray { 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 { + pub(crate) 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 { + pub(crate) 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> { + pub(crate) fn values(&self) -> Vec<&str> { self.entries .iter() .filter_map(|e| match e { @@ -105,7 +97,7 @@ impl SpliceArray { /// - If the array has no splice marker, it replaces the inherited list /// wholesale. #[must_use] - pub fn resolve(self, inherited: Vec) -> Vec { + pub(crate) fn resolve(self, inherited: Vec) -> Vec { let Some(pos) = self.splice_position() else { return self .entries diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index bf8cfca31..326ab2062 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -461,7 +461,9 @@ retros = true #[test] fn parse_higher_version_errors() { - let err = "_version = 2\n".parse::().unwrap_err(); + let err = "_version = 2\n" + .parse::() + .unwrap_err(); let chain = format!("{err:#}"); assert!( chain.contains("Upgrade") || chain.to_lowercase().contains("version"), diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs index 16470d06c..be3677265 100644 --- a/lib/crates/fabro-config/src/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -4,7 +4,9 @@ use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use crate::{Combine, SettingsLayer, StringOrSplice}; fn parse(input: &str) -> SettingsLayer { - input.parse::().expect("fixture should parse") + input + .parse::() + .expect("fixture should parse") } #[test] diff --git a/lib/crates/fabro-config/src/tests/defaults.rs b/lib/crates/fabro-config/src/tests/defaults.rs index 51044b4fa..da17b5dc3 100644 --- a/lib/crates/fabro-config/src/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -5,7 +5,9 @@ use fabro_types::settings::server::ObjectStoreProvider; use crate::{Combine, ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder}; fn parse(source: &str) -> SettingsLayer { - source.parse::().expect("fixture should parse") + source + .parse::() + .expect("fixture should parse") } fn embedded_defaults() -> SettingsLayer { diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs index d545786ac..fb159b6f5 100644 --- a/lib/crates/fabro-config/src/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -15,7 +15,9 @@ use crate::user::default_storage_dir; use crate::{ServerSettingsBuilder, SettingsLayer}; fn parse(source: &str) -> SettingsLayer { - let mut layer = source.parse::().expect("fixture should parse"); + let mut layer = source + .parse::() + .expect("fixture should parse"); layer.ensure_test_auth_methods(); layer } diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index b0b49f659..5d94df8c6 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -1032,7 +1032,7 @@ root = "/srv/fabro" script = "cli-setup" [server.integrations.github] -app_id = "snapshotted-app-id" +app_id = "fixture-app-id" "#, ))); @@ -1061,7 +1061,7 @@ methods = ["dev-token"] script = "cli-setup" [server.integrations.github] -app_id = "snapshotted-app-id" +app_id = "fixture-app-id" "# .to_string(), ), From f16becd2f72778672251b0f4b513704d07d763bb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 19:53:40 -0400 Subject: [PATCH 55/60] clean up settings warning fallout --- lib/crates/fabro-config/src/builders.rs | 18 +- .../fabro-config/src/layers/splice_array.rs | 252 +-------- lib/crates/fabro-config/src/project.rs | 17 +- lib/crates/fabro-config/src/resolve/mod.rs | 2 - lib/crates/fabro-config/src/resolve/server.rs | 11 +- lib/crates/fabro-config/src/run.rs | 11 - .../fabro-config/src/tests/resolve_server.rs | 15 +- lib/crates/fabro-types/src/settings/cli.rs | 102 +--- .../fabro-types/src/settings/combine.rs | 344 ------------- .../fabro-types/src/settings/features.rs | 11 - lib/crates/fabro-types/src/settings/maps.rs | 207 -------- lib/crates/fabro-types/src/settings/mod.rs | 9 +- .../fabro-types/src/settings/project.rs | 18 - lib/crates/fabro-types/src/settings/run.rs | 480 +----------------- lib/crates/fabro-types/src/settings/server.rs | 253 --------- .../fabro-types/src/settings/splice_array.rs | 261 ---------- .../fabro-types/src/settings/workflow.rs | 17 - 17 files changed, 38 insertions(+), 1990 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/combine.rs delete mode 100644 lib/crates/fabro-types/src/settings/maps.rs delete mode 100644 lib/crates/fabro-types/src/settings/splice_array.rs diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index ed714a5aa..f3639b137 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -32,6 +32,15 @@ impl ResolveErrors { } } +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 @@ -426,15 +435,6 @@ impl WorkflowSettingsBuilder { let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); finish_dense_result(workflow, errors) } - - pub(crate) fn run_from_layer( - layer: &SettingsLayer, - ) -> std::result::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_dense_result(run, errors) - } } fn finish_result(value: T, context: &'static str, errors: Vec) -> Result { diff --git a/lib/crates/fabro-config/src/layers/splice_array.rs b/lib/crates/fabro-config/src/layers/splice_array.rs index 51a033907..abcfb24c8 100644 --- a/lib/crates/fabro-config/src/layers/splice_array.rs +++ b/lib/crates/fabro-config/src/layers/splice_array.rs @@ -1,253 +1,3 @@ -//! 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. +//! Shared splice marker literal for splice-capable arrays in raw settings. -use std::fmt; - -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// The reserved literal that marks the splice insertion point. pub(crate) const SPLICE_MARKER: &str = "..."; - -/// A string array that may contain at most one splice marker. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) 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(crate) 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(crate) 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 }) - } - - /// True when the array contains a splice marker. - #[must_use] - pub(crate) 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(crate) fn splice_position(&self) -> Option { - self.entries.iter().position(|e| matches!(e, Entry::Splice)) - } - - /// The non-splice values, in source order. - #[must_use] - pub(crate) 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(crate) 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-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 326ab2062..e34cb9346 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,7 +12,7 @@ use std::fmt::Write; use std::path::{Component, Path, PathBuf}; -use fabro_types::settings::RunNamespace; +use fabro_types::settings::{InterpString, RunNamespace}; use serde::Serialize; use crate::load::load_settings_path; @@ -108,15 +108,8 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result PathBuf { - let Some(run_settings) = WorkflowSettingsBuilder::run_from_layer(settings).ok() else { - return caller_cwd.to_path_buf(); - }; - resolve_working_directory_from_run(&run_settings, caller_cwd) -} - pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf { - let Some(work_dir) = run.working_dir.as_ref().map(|value| value.as_source()) else { + 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); @@ -588,9 +581,9 @@ file = "prompts/goal.md" fn resolve_working_directory_from_run_joins_relative_path() { let cwd = Path::new("/tmp/workspace"); let resolved = resolve_working_directory_from_run( - &fabro_types::settings::RunNamespace { - working_dir: Some(fabro_types::settings::InterpString::parse("repo")), - ..fabro_types::settings::RunNamespace::default() + &RunNamespace { + working_dir: Some(InterpString::parse("repo")), + ..RunNamespace::default() }, cwd, ); diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 88edbd070..0a9ca6528 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -12,8 +12,6 @@ use fabro_types::settings::InterpString; pub use features::resolve_features; pub use project::resolve_project; pub use run::resolve_run; -#[cfg(test)] -pub(crate) use server::dev_token_auth_enabled; pub use server::resolve_server; pub use workflow::resolve_workflow; diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 891d575a1..eeb8371ca 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -16,18 +16,9 @@ use crate::{ IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer, - ServerStorageLayer, ServerWebLayer, SettingsLayer, + ServerStorageLayer, ServerWebLayer, }; -pub(crate) 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)) -} - pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerNamespace { let storage = resolve_storage(layer.storage.as_ref()); let listen = resolve_listen(layer.listen.as_ref(), errors); diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index bb512d943..86e4f0866 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -68,17 +68,6 @@ impl std::error::Error for ResolveRunGoalError { } } -pub(crate) fn resolve_run_goal( - settings: &SettingsLayer, - base_dir: &Path, -) -> std::result::Result, ResolveRunGoalError> { - let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else { - return Ok(None); - }; - - resolve_layer_goal(goal, base_dir).map(Some) -} - pub fn resolve_run_goal_from_layer( run: &RunLayer, base_dir: &Path, diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs index fb159b6f5..febec8178 100644 --- a/lib/crates/fabro-config/src/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -5,12 +5,12 @@ use fabro_types::settings::InterpString; use fabro_types::settings::server::{ - GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings, + GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod, + ServerListenSettings, ServerNamespace, }; use fabro_util::Home; use temp_env::with_var; -use crate::resolve::dev_token_auth_enabled; use crate::user::default_storage_dir; use crate::{ServerSettingsBuilder, SettingsLayer}; @@ -26,7 +26,16 @@ fn empty_settings_with_auth_methods() -> SettingsLayer { SettingsLayer::test_default() } -fn resolve_server(file: &SettingsLayer) -> fabro_types::settings::ServerNamespace { +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 diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index 65f3055a9..19aa30d76 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -10,8 +10,7 @@ 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, Deserialize)] @@ -71,47 +70,6 @@ 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) struct CliExecLayer { - /// Prevent idle sleep on macOS while an exec run is in flight. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CliExecModelLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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-types/src/settings/combine.rs deleted file mode 100644 index 9a781d955..000000000 --- a/lib/crates/fabro-types/src/settings/combine.rs +++ /dev/null @@ -1,344 +0,0 @@ -use std::collections::HashMap; - -use super::cli::{ - CliAuthLayer, CliAuthStrategy, CliLoggingLayer, CliTargetLayer, OutputFormat, OutputVerbosity, -}; -use super::duration::Duration; -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, -}; -use super::server::{ - GithubIntegrationStrategy, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer, - ServerApiLayer, ServerAuthGithubLayer, ServerAuthMethod, ServerListenLayer, ServerLoggingLayer, - WebhookStrategy, -}; -use super::size::Size; - -pub(crate) trait Combine { - /// Combine two values, preferring the values in `self`. - #[must_use] - fn combine(self, other: Self) -> Self; -} - -impl Combine for Option { - fn combine(self, other: Self) -> Self { - match (self, other) { - (Some(this), Some(fallback)) => Some(this.combine(fallback)), - (this, fallback) => this.or(fallback), - } - } -} - -macro_rules! impl_combine_or_option { - ($($ty:ty),+ $(,)?) => { - $( - impl Combine for Option<$ty> { - fn combine(self, other: Self) -> Self { - self.or(other) - } - } - )+ - }; -} - -impl_combine_or_option!( - String, - bool, - u16, - u32, - u64, - usize, - i32, - Duration, - InterpString, - Size, - CliAuthStrategy, - OutputFormat, - OutputVerbosity, - AgentPermissions, - ApprovalMode, - HookAgentMarker, - HookTlsMode, - MergeStrategy, - RunMode, - WorktreeMode, - GithubIntegrationStrategy, - ObjectStoreProvider, - ServerAuthMethod, - WebhookStrategy, -); - -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - -macro_rules! impl_combine_self { - ($($ty:ty),+ $(,)?) => { - $( - impl Combine for $ty { - fn combine(self, _other: Self) -> Self { - self - } - } - )+ - }; -} - -impl_combine_self!( - CliAuthLayer, - CliLoggingLayer, - CliTargetLayer, - FeaturesLayer, - DaytonaNetworkLayer, - DaytonaSnapshotLayer, - InterviewProviderLayer, - LocalSandboxLayer, - NotificationProviderLayer, - RunArtifactsLayer, - RunGoalLayer, - RunPrepareLayer, - ScmGitHubLayer, - ObjectStoreLocalLayer, - ObjectStoreS3Layer, - ServerApiLayer, - ServerAuthGithubLayer, - ServerListenLayer, - ServerLoggingLayer, -); - -impl Combine for RunCheckpointLayer { - fn combine(self, other: Self) -> Self { - if self.exclude_globs.is_empty() { - other - } else { - self - } - } -} - -/// 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(crate) trait SpliceMarker { - fn is_splice(&self) -> bool; -} - -impl SpliceMarker for ModelRefOrSplice { - fn is_splice(&self) -> bool { - matches!(self, Self::Splice) - } -} - -impl SpliceMarker for StringOrSplice { - fn is_splice(&self) -> bool { - matches!(self, Self::Splice) - } -} - -impl Combine for Vec { - fn combine(self, other: Self) -> Self { - splice_combine(other, self) - } -} - -impl Combine for Vec { - fn combine(self, other: Self) -> Self { - combine_hooks(&other, self) - } -} - -fn splice_combine(fallback: Vec, current: Vec) -> Vec { - if current.is_empty() { - return fallback; - } - let Some(pos) = current.iter().position(T::is_splice) else { - return current; - }; - let mut out = Vec::with_capacity(current.len() - 1 + fallback.len()); - for (index, entry) in current.into_iter().enumerate() { - if index == pos { - out.extend(fallback.iter().filter(|entry| !entry.is_splice()).cloned()); - } else if !entry.is_splice() { - out.push(entry); - } - } - out -} - -fn combine_hooks(fallback: &[HookEntry], current: Vec) -> Vec { - let mut out = Vec::with_capacity(fallback.len() + current.len()); - let mut appended_ids = Vec::new(); - - for fallback_entry in fallback { - if let Some(id) = &fallback_entry.id { - if let Some(replacement) = current - .iter() - .find(|entry| entry.id.as_deref() == Some(id.as_str())) - { - out.push(replacement.clone()); - appended_ids.push(id.clone()); - continue; - } - } - out.push(fallback_entry.clone()); - } - - for current_entry in current { - if let Some(id) = ¤t_entry.id { - if appended_ids.contains(id) { - continue; - } - } - out.push(current_entry); - } - - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Debug, PartialEq)] - struct FieldMergeLayer { - a: Option, - b: Option, - } - - impl Combine for FieldMergeLayer { - fn combine(self, other: Self) -> Self { - Self { - a: self.a.combine(other.a), - b: self.b.combine(other.b), - } - } - } - - #[derive(Debug, PartialEq)] - struct WholeReplaceLayer { - a: Option, - b: Option, - } - - impl Combine for WholeReplaceLayer { - fn combine(self, _other: Self) -> Self { - self - } - } - - #[track_caller] - fn assert_option_leaf(this: T, fallback: T) - where - T: Clone + std::fmt::Debug + PartialEq, - Option: Combine, - { - assert_eq!( - Some(this.clone()).combine(Some(fallback.clone())), - Some(this) - ); - assert_eq!( - Option::::None.combine(Some(fallback.clone())), - Some(fallback) - ); - } - - #[test] - fn option_leaf_types_prefer_self_or_fallback() { - assert_option_leaf("this".to_string(), "fallback".to_string()); - assert_option_leaf(true, false); - assert_option_leaf(1_u16, 2_u16); - assert_option_leaf(1_u32, 2_u32); - assert_option_leaf(1_u64, 2_u64); - assert_option_leaf(1_usize, 2_usize); - assert_option_leaf(1_i32, 2_i32); - assert_option_leaf(Duration::from_secs(1), Duration::from_secs(2)); - assert_option_leaf(InterpString::parse("this"), InterpString::parse("fallback")); - assert_option_leaf(Size::from_bytes(1), Size::from_bytes(2)); - assert_option_leaf(CliAuthStrategy::None, CliAuthStrategy::Jwt); - assert_option_leaf(OutputFormat::Json, OutputFormat::Text); - assert_option_leaf(OutputVerbosity::Quiet, OutputVerbosity::Verbose); - assert_option_leaf(AgentPermissions::ReadOnly, AgentPermissions::Full); - assert_option_leaf(ApprovalMode::Auto, ApprovalMode::Prompt); - assert_option_leaf(HookAgentMarker::Enabled, HookAgentMarker::Enabled); - assert_option_leaf(HookTlsMode::NoVerify, HookTlsMode::Verify); - assert_option_leaf(MergeStrategy::Rebase, MergeStrategy::Squash); - assert_option_leaf(RunMode::DryRun, RunMode::Normal); - assert_option_leaf(WorktreeMode::Always, WorktreeMode::Never); - assert_option_leaf( - GithubIntegrationStrategy::App, - GithubIntegrationStrategy::Token, - ); - assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local); - assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken); - assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel); - assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]); - assert_option_leaf(vec![ServerAuthMethod::Github], vec![ - ServerAuthMethod::DevToken, - ]); - assert_option_leaf( - HashMap::from([("this".to_string(), toml::Value::String("value".to_string()))]), - HashMap::from([( - "fallback".to_string(), - toml::Value::String("value".to_string()), - )]), - ); - } - - #[test] - fn recursive_option_combines_inner_fields() { - let this = Some(FieldMergeLayer { - a: Some(1), - b: None, - }); - let fallback = Some(FieldMergeLayer { - a: Some(2), - b: Some(3), - }); - - assert_eq!( - this.combine(fallback), - Some(FieldMergeLayer { - a: Some(1), - b: Some(3), - }) - ); - } - - #[test] - fn whole_replace_inner_does_not_inherit_fallback_fields() { - let this = Some(WholeReplaceLayer { - a: Some(1), - b: None, - }); - let fallback = Some(WholeReplaceLayer { - a: Some(2), - b: Some(3), - }); - - assert_eq!( - this.combine(fallback), - Some(WholeReplaceLayer { - a: Some(1), - b: None, - }) - ); - } -} diff --git a/lib/crates/fabro-types/src/settings/features.rs b/lib/crates/fabro-types/src/settings/features.rs index 29df5e09c..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(crate) 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-types/src/settings/maps.rs deleted file mode 100644 index 21db83484..000000000 --- a/lib/crates/fabro-types/src/settings/maps.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::collections::HashMap; -use std::collections::hash_map::IntoIter; -use std::ops::{Deref, DerefMut}; - -use serde::{Deserialize, Serialize}; - -use super::combine::Combine; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct ReplaceMap(pub HashMap); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct StickyMap(pub HashMap); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct MergeMap(pub HashMap); - -macro_rules! impl_map_wrapper { - ($name:ident) => { - impl $name { - #[must_use] - pub(crate) fn is_empty(&self) -> bool { - self.0.is_empty() - } - - #[must_use] - pub(crate) fn into_inner(self) -> HashMap { - self.0 - } - } - - impl Deref for $name { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl DerefMut for $name { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } - } - - impl From> for $name { - fn from(value: HashMap) -> Self { - Self(value) - } - } - - impl Default for $name { - fn default() -> Self { - Self(HashMap::new()) - } - } - - impl IntoIterator for $name { - type IntoIter = IntoIter; - type Item = (String, V); - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } - } - }; -} - -impl_map_wrapper!(ReplaceMap); -impl_map_wrapper!(StickyMap); -impl_map_wrapper!(MergeMap); - -impl Combine for ReplaceMap { - fn combine(self, other: Self) -> Self { - if self.0.is_empty() { other } else { self } - } -} - -impl Combine for StickyMap { - fn combine(self, other: Self) -> Self { - let mut combined = other.0; - for (key, value) in self.0 { - combined.insert(key, value); - } - Self(combined) - } -} - -impl Combine for MergeMap { - fn combine(self, other: Self) -> Self { - let mut combined = other.0; - for (key, value) in self.0 { - let value = match combined.remove(&key) { - Some(fallback) => value.combine(fallback), - None => value, - }; - combined.insert(key, value); - } - Self(combined) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Debug, PartialEq)] - struct ValueLayer { - a: Option, - b: Option, - } - - impl Combine for ValueLayer { - fn combine(self, other: Self) -> Self { - Self { - a: self.a.combine(other.a), - b: self.b.combine(other.b), - } - } - } - - #[test] - fn replace_map_self_wins_when_non_empty() { - let this = ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])); - let fallback = ReplaceMap(HashMap::from([ - ("a".to_string(), "fallback".to_string()), - ("b".to_string(), "fallback".to_string()), - ])); - - assert_eq!( - this.combine(fallback), - ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])) - ); - } - - #[test] - fn replace_map_empty_self_uses_fallback() { - let this = ReplaceMap::(HashMap::new()); - let fallback = ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])); - - assert_eq!( - this.combine(fallback), - ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])) - ); - } - - #[test] - fn replace_map_round_trips_as_toml_table() { - let parsed: ReplaceMap = - toml::from_str(r#"a = "one""#).expect("fixture should deserialize"); - - assert_eq!( - parsed, - ReplaceMap(HashMap::from([("a".to_string(), "one".to_string())])) - ); - - let serialized = toml::to_string(&parsed).expect("fixture should serialize"); - let reparsed: ReplaceMap = - toml::from_str(&serialized).expect("fixture should deserialize again"); - - assert_eq!(reparsed, parsed); - } - - #[test] - fn sticky_map_merges_keys_with_self_winning_conflicts() { - let this = StickyMap(HashMap::from([ - ("a".to_string(), "this".to_string()), - ("c".to_string(), "this".to_string()), - ])); - let fallback = StickyMap(HashMap::from([ - ("a".to_string(), "fallback".to_string()), - ("b".to_string(), "fallback".to_string()), - ])); - - assert_eq!( - this.combine(fallback), - StickyMap(HashMap::from([ - ("a".to_string(), "this".to_string()), - ("b".to_string(), "fallback".to_string()), - ("c".to_string(), "this".to_string()), - ])) - ); - } - - #[test] - fn merge_map_recursively_combines_values_for_matching_keys() { - let this = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { - a: Some("this".to_string()), - b: None, - })])); - let fallback = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { - a: Some("fallback".to_string()), - b: Some("fallback".to_string()), - })])); - - assert_eq!( - this.combine(fallback), - MergeMap(HashMap::from([("ops".to_string(), ValueLayer { - a: Some("this".to_string()), - b: Some("fallback".to_string()), - },)])) - ); - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 99da38e81..81c31f134 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,26 +1,23 @@ //! 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; -mod combine; pub mod duration; pub mod features; pub mod interp; -mod maps; pub mod model_ref; pub mod project; pub mod run; pub mod server; pub mod size; -mod splice_array; pub mod workflow; pub use cli::{ diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index 470076b8e..7e141dbcb 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -7,8 +7,6 @@ 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, Deserialize)] pub struct ProjectNamespace { @@ -17,19 +15,3 @@ pub struct ProjectNamespace { 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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 a1033eb15..1d4626aef 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -12,9 +12,7 @@ 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. @@ -399,6 +397,10 @@ pub struct RunScmSettings { pub github: Option, } +#[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 {} @@ -425,82 +427,10 @@ impl Default for PullRequestSettings { 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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, @@ -517,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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) enum ModelRefOrSplice { - ModelRef(ModelRef), - Splice, -} - -impl Serialize for ModelRefOrSplice { - fn serialize(&self, serializer: S) -> Result { - match self { - Self::ModelRef(m) => m.serialize(serializer), - Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), - } - } -} - -impl<'de> Deserialize<'de> for ModelRefOrSplice { - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error; - let raw = String::deserialize(deserializer)?; - if raw == super::splice_array::SPLICE_MARKER { - return Ok(Self::Splice); - } - let model = raw.parse::().map_err(D::Error::custom)?; - Ok(Self::ModelRef(model)) - } -} - -/// `[run.git]` — local git behavior such as commit author. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct RunGitLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub author: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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)] @@ -600,19 +459,6 @@ pub struct PrepareStep { pub env: HashMap, } -/// `[run.execution]` — run posture knobs. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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 { @@ -627,40 +473,6 @@ pub enum ApprovalMode { Auto, } -/// `[run.checkpoint]` — checkpoint policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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 { @@ -671,44 +483,6 @@ pub enum WorktreeMode { Never, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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(crate) enum DaytonaDockerfileLayer { - Inline(String), - Path { path: String }, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum DaytonaNetworkLayer { @@ -717,93 +491,6 @@ pub enum DaytonaNetworkLayer { AllowList { allow_list: Vec }, } -/// `[run.notifications.]` — a keyed notification route. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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(crate) struct NotificationProviderLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, -} - -/// `[run.interviews]` — external interview delivery. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) struct InterviewProviderLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, -} - -/// `[run.agent]` — agent knobs only (permissions, MCPs). -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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 { @@ -812,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(crate) 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(crate) 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(crate) 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(crate) enum HookAgentMarker { - #[default] - Enabled, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HookEvent { @@ -944,42 +520,6 @@ pub enum HookEvent { PostToolUseFailure, } -/// `[run.scm]` — remote SCM host/provider behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) struct ScmGitHubLayer; - -/// `[run.pull_request]` — provider-neutral PR behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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 { @@ -987,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(crate) 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 9e49f0486..410a02dd6 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -15,7 +15,6 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::duration::Duration as DurationLayer; use super::interp::InterpString; -use super::maps::StickyMap; /// A structurally resolved `[server]` view for consumers. /// @@ -306,147 +305,6 @@ where 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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(crate) struct ServerApiLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -/// `[server.web]` — web surface settings. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct ServerWebLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -/// `[server.auth]` — cohesive server auth surface. -/// -/// When absent or resolved to no enabled API or web auth configuration, the -/// default server startup posture is fail-closed. Demo and test helpers may -/// explicitly opt in to insecure configurations. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) struct ServerAuthGithubLayer { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_usernames: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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, -} - /// Closed enum of object-store providers. Unknown providers hard-fail /// against the schema rather than passing through as opaque strings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -456,117 +314,6 @@ pub enum ObjectStoreProvider { S3, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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(crate) struct ServerLoggingLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub level: Option, -} - -/// `[server.integrations.]` — cohesive integration surface for chat -/// platforms and git providers (GitHub App, webhooks, etc.). First-pass -/// integrations enumerate known providers rather than using a flatten-HashMap -/// shape so strict unknown-field validation still holds. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct ServerIntegrationsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discord: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, -} - -/// `[server.integrations.github]` — GitHub App, credentials, and inbound -/// webhooks. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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)] -#[serde(deny_unknown_fields)] -pub(crate) struct SlackIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_channel: Option, -} - -/// `[server.integrations.discord]` — Discord workspace configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct DiscordIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, -} - -/// `[server.integrations.teams]` — Microsoft Teams configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct TeamsIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) 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 ed435be5e..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(crate) const SPLICE_MARKER: &str = "..."; - -/// A string array that may contain at most one splice marker. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn splice_position(&self) -> Option { - self.entries.iter().position(|e| matches!(e, Entry::Splice)) - } - - /// The non-splice values, in source order. - #[must_use] - pub(crate) 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(crate) 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 4f356d6c3..fd3a5b8b9 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -7,8 +7,6 @@ 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, Deserialize)] pub struct WorkflowNamespace { @@ -17,18 +15,3 @@ pub struct WorkflowNamespace { 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)] -#[serde(deny_unknown_fields)] -pub(crate) 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, -} From d380c8f496c739928715fd63bb4b62126f8ca781 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 20:06:19 -0400 Subject: [PATCH 56/60] drop vestigial fabro-macros dep and DurationLayer aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Unit 3.1 of the config boundary refactor, fabro-types no longer has any #[derive(Combine)] sites — the fabro-macros dep is unused. Likewise `Duration as DurationLayer` was an artifact from when layer and vocabulary types lived side-by-side; the resolved Duration type has no Layer form now, so the alias was misleading. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 - lib/crates/fabro-config/src/layers/server.rs | 4 ++-- lib/crates/fabro-types/Cargo.toml | 1 - lib/crates/fabro-types/src/settings/server.rs | 6 +++--- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 074221793..4becde6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,7 +2210,6 @@ dependencies = [ "chrono", "clap", "dirs", - "fabro-macros", "fabro-model", "fabro-util", "hex", diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/crates/fabro-config/src/layers/server.rs index 96a4538d0..8b4d30e02 100644 --- a/lib/crates/fabro-config/src/layers/server.rs +++ b/lib/crates/fabro-config/src/layers/server.rs @@ -3,7 +3,7 @@ use fabro_types::settings::server::{ GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, }; -use fabro_types::settings::{Duration as DurationLayer, InterpString}; +use fabro_types::settings::{Duration, InterpString}; use serde::{Deserialize, Serialize}; use super::maps::StickyMap; @@ -139,7 +139,7 @@ pub struct ServerSlateDbLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub flush_interval: Option, + pub flush_interval: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub local: Option, #[serde(default, skip_serializing_if = "Option::is_none")] 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/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 410a02dd6..a80937d78 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -13,7 +13,7 @@ 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; /// A structurally resolved `[server]` view for consumers. @@ -295,14 +295,14 @@ fn serialize_std_duration(value: &StdDuration, serializer: S) -> Result(deserializer: D) -> Result where D: Deserializer<'de>, { - Ok(DurationLayer::deserialize(deserializer)?.as_std()) + Ok(Duration::deserialize(deserializer)?.as_std()) } /// Closed enum of object-store providers. Unknown providers hard-fail From 64dbdf250072a2acb47115746e7cdc8af2020f35 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 20:49:30 -0400 Subject: [PATCH 57/60] fix(ci): resolve workspace test and lint regressions --- lib/crates/fabro-cli/src/command_context.rs | 23 +++--- lib/crates/fabro-cli/src/commands/exec.rs | 6 +- .../fabro-cli/src/commands/uninstall.rs | 5 +- lib/crates/fabro-cli/src/local_server.rs | 17 ++-- lib/crates/fabro-cli/src/manifest_builder.rs | 2 +- lib/crates/fabro-cli/src/user_config.rs | 78 +++++++++++++++++-- lib/crates/fabro-config/src/builders.rs | 65 +++++++++++++++- lib/crates/fabro-server/src/run_manifest.rs | 5 +- lib/crates/fabro-server/src/serve.rs | 2 +- lib/crates/fabro-server/src/server.rs | 13 +--- lib/crates/fabro-server/tests/it/api/tcp.rs | 13 ++-- lib/crates/fabro-server/tests/it/helpers.rs | 23 +++--- lib/crates/fabro-store/src/run_state.rs | 2 +- .../fabro-workflow/src/operations/source.rs | 5 +- .../fabro-workflow/src/operations/start.rs | 8 +- 15 files changed, 190 insertions(+), 77 deletions(-) diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 8be555870..4e79dbf48 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -199,16 +199,16 @@ fn load_merged_settings( Some(cli_layer), )?, }; - resolve_command_settings(loaded_settings) + Ok(resolve_command_settings(loaded_settings)) } -fn resolve_command_settings(loaded_settings: LoadedSettings) -> Result { - Ok(ResolvedCommandSettings { +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, - }) + } } #[cfg(test)] @@ -238,8 +238,7 @@ mod tests { let resolved_settings = resolve_command_settings( user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer)) .expect("settings should resolve"), - ) - .expect("settings should merge"); + ); CommandContext { printer, process_local_json, @@ -283,8 +282,7 @@ root = "/srv/fabro/default" Some(&cli_layer), ) .expect("base settings should resolve"), - ) - .expect("base settings should merge"); + ); let connection_settings = resolve_command_settings( user_config::load_resolved_settings_from_toml( r#" @@ -297,8 +295,7 @@ root = "/srv/fabro/default" Some(&cli_layer), ) .expect("connection settings should resolve"), - ) - .expect("connection settings should merge"); + ); assert_eq!( base_settings.user_settings, @@ -339,8 +336,7 @@ root = "/srv/fabro" Some(&CliLayer::default()), ) .expect("settings should resolve"), - ) - .expect("settings should merge"); + ); assert_eq!(resolved.storage_dir, PathBuf::from("/srv/fabro")); assert!(resolved.run_settings.is_ok()); @@ -362,8 +358,7 @@ command = ["demo-mcp"] Some(&CliLayer::default()), ) .expect("settings should resolve"), - ) - .expect("settings should merge"); + ); let run_settings = resolved.run_settings.expect("run settings should resolve"); assert!(run_settings.agent.mcps.contains_key("demo")); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index b871b4056..78578838f 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -304,12 +304,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().cloned().collect() - } else { + 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/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index 2e660d84b..f29f3b73e 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -59,8 +59,9 @@ pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) -> let storage_dir = local_server::LocalServerConfig::load_with_storage_dir(None) .ok() - .map(|settings| settings.storage_dir().to_path_buf()) - .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/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 14988105c..21f6cf427 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_config::bind::BindRequest; +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}; @@ -58,7 +60,7 @@ impl LocalServerConfig { .server_settings .as_ref() .map_err(|err| anyhow::anyhow!("{err}"))?; - fabro_server::serve::resolve_bind_request_from_server_settings(settings, cli_override) + resolve_bind_request_from_server_settings(settings, cli_override) } } @@ -72,11 +74,10 @@ fn storage_dir_from_toml_with_lookup( ) -> Result { 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(|root| InterpString::parse(&root)) - .unwrap_or_else(|| { - InterpString::parse(&fabro_config::user::default_storage_dir().to_string_lossy()) - }); + 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()))?; @@ -95,6 +96,8 @@ fn string_at_path(document: &toml::Value, path: &[&str]) -> Option { mod tests { use std::path::PathBuf; + use fabro_config::user::default_storage_dir; + use super::{storage_dir_from_toml, storage_dir_from_toml_with_lookup}; #[test] @@ -116,7 +119,7 @@ root = "/srv/fabro" 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, fabro_config::user::default_storage_dir()); + assert_eq!(path, default_storage_dir()); } #[test] diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 1f41459ef..e325c0309 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -352,7 +352,7 @@ fn collect_workflow_config_files( .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; let run = document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))? .unwrap_or_default(); diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index fa279d06e..078eab5c7 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -3,10 +3,10 @@ use std::str::FromStr; use anyhow::Result; pub(crate) use fabro_client::ServerTarget; -use fabro_config::user::default_socket_path; -pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir}; +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, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, load_config_file, + CliLayer, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder, }; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliNamespace, InterpString, RunNamespace}; @@ -52,7 +52,40 @@ pub(crate) fn load_resolved_settings( } fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result { - let table: toml::Table = load_config_file(config_path, "settings.toml")?; + 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)) } @@ -104,9 +137,10 @@ fn storage_dir_from_document_with_lookup( return Ok(dir.to_path_buf()); } - let storage_root = string_at_path(document, &["server", "storage", "root"]) - .map(|root| InterpString::parse(&root)) - .unwrap_or_else(|| InterpString::parse(&default_storage_dir().to_string_lossy())); + 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)) } @@ -378,4 +412,34 @@ root = "{{ env.FABRO_STORAGE_ROOT }}" 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-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index f3639b137..ab6b041f5 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -283,7 +283,7 @@ impl WorkflowSettingsBuilder { #[must_use] pub(crate) fn args_layer(mut self, layer: SettingsLayer) -> Self { - self.args = layer; + self.args = layer.combine(self.args); self } @@ -458,9 +458,14 @@ fn finish_dense_result( #[cfg(test)] mod tests { - use fabro_types::settings::run::RunMode; + use std::collections::HashMap; - use super::RunSettingsBuilder; + 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() { @@ -481,4 +486,58 @@ command = ["demo-mcp"] 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-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 5d94df8c6..c5cc7b87d 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -55,6 +55,7 @@ struct ManifestSettingsOverrides { cli: Option, } +#[cfg(test)] pub(crate) fn manifest_run_defaults(run: Option<&RunLayer>) -> RunLayer { run.cloned().unwrap_or_default() } @@ -226,7 +227,7 @@ fn root_workflow_run_layer( .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?; let mut run = document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))? .unwrap_or_default(); @@ -975,7 +976,7 @@ mod tests { let mut document: toml::Table = source.parse().expect("v2 fixture should parse"); document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .expect("run settings should parse") .unwrap_or_default() diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index f1d9944d0..1ecfb2ede 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -1067,7 +1067,7 @@ mod tests { let mut document: toml::Table = source.parse().expect("v2 fixture should parse"); document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .expect("run settings should parse") .unwrap_or_default() diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index cd2e17365..4ff628b2f 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2658,10 +2658,6 @@ pub(crate) fn create_test_app_state_with_runtime_settings_and_session_key( } #[cfg(test)] -#[expect( - 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( server_settings: ServerSettings, manifest_run_defaults: RunLayer, @@ -7491,7 +7487,7 @@ mod tests { let mut document: toml::Table = source.parse().expect("run defaults should parse"); document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .expect("run defaults should parse") .unwrap_or_default() @@ -11045,11 +11041,8 @@ timeout = "30s" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrency_limit_respected() { - let state = create_app_state_with_options( - default_test_server_settings(), - RunLayer::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/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 0b21583e3..733211846 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -106,14 +106,11 @@ async fn spawn_served_listener( .await }); - let bind = match rx.await { - Ok(bind) => bind, - Err(_) => { - let result = handle - .await - .expect("server task should not panic before reporting readiness"); - panic!("server should report its bind address: {result:?}"); - } + 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) } diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 667ca826d..c1c062c73 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -62,7 +62,7 @@ pub(crate) fn settings_from_toml(source: &str) -> TestAppSettings { ensure_test_auth_methods(&mut document); let manifest_run_defaults = document .remove("run") - .map(|value| value.try_into::()) + .map(toml::Value::try_into::) .transpose() .expect("test run settings should parse") .unwrap_or_default(); @@ -93,17 +93,18 @@ pub(crate) fn test_app_state_with_options( } pub(crate) fn test_settings() -> TestAppSettings { - let mut settings = TestAppSettings::default(); - settings.manifest_run_defaults = RunLayer { - sandbox: Some(RunSandboxLayer { - local: Some(LocalSandboxLayer { - worktree_mode: Some(WorktreeMode::Never), + TestAppSettings { + manifest_run_defaults: RunLayer { + sandbox: Some(RunSandboxLayer { + local: Some(LocalSandboxLayer { + worktree_mode: Some(WorktreeMode::Never), + }), + ..RunSandboxLayer::default() }), - ..RunSandboxLayer::default() - }), - ..RunLayer::default() - }; - settings + ..RunLayer::default() + }, + ..TestAppSettings::default() + } } pub(crate) fn test_app_with_scheduler(state: Arc) -> axum::Router { diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index b4c3f39bf..126201ec2 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -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", diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index e0bc5d969..051818597 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; -use fabro_config::project::resolve_working_directory_from_run; +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; @@ -65,8 +65,7 @@ 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 = - fabro_config::project::resolve_workflow_path(&workflow_path, &request.cwd)?; + let resolution = resolve_workflow_path(&workflow_path, &request.cwd)?; let settings = request.settings; let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index f65d90452..ff21b2e8f 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -311,7 +311,7 @@ impl RunSession { 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 @@ -366,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()), @@ -434,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, @@ -971,8 +971,8 @@ mod tests { use chrono::Utc; use fabro_config::{RunExecutionLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_store::Database; - use fabro_types::{WorkflowSettings, fixtures}; use fabro_types::settings::run::RunMode; + use fabro_types::{WorkflowSettings, fixtures}; use object_store::memory::InMemory; use super::*; From f0fadffb5e4f57d8afc19f02b770657afddb9657 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 21:02:02 -0400 Subject: [PATCH 58/60] apply rustfmt to settings consumer tests Three test modules drifted to non-canonical style during the post-merge CI fixup; reformatting brings them back in line with the pinned nightly rustfmt config so `cargo +nightly-2026-04-14 fmt --check --all` is clean again. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/install.rs | 7 +++---- lib/crates/fabro-server/src/auth/cli_flow.rs | 2 +- lib/crates/fabro-workflow/src/operations/create.rs | 3 ++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 640b37a20..5e3ac7915 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -2003,10 +2003,9 @@ mod tests { let toml_str = format_config_toml(); 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] - ); + assert_eq!(cfg.server.auth.methods, vec![ + fabro_types::settings::ServerAuthMethod::DevToken + ]); } #[test] diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index c2bdc3d61..491772236 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -1299,9 +1299,9 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::http::{HeaderMap, Request, StatusCode, header}; use axum_extra::extract::cookie::Key; - use fabro_config::{RunLayer, ServerSettingsBuilder}; 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::server::ServerAuthMethod; use serde_json::json; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 0ded3d72f..4d75e0961 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -412,7 +412,8 @@ mod tests { }; use fabro_graphviz::graph::AttrValue; use fabro_store::Database; - use fabro_types::settings::{InterpString, run::RunMode}; + 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; From 906868c30cf4c0a53a6ead89e01380c125e28637 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 22:29:29 -0400 Subject: [PATCH 59/60] drop unused test-support feature from fabro-config SettingsLayer::{test_default, ensure_test_auth_methods} are `pub(crate)` and only called from fabro-config's own in-crate tests, but their impl block was gated on `cfg(any(test, feature = "test-support"))`. No external crate enabled the `test-support` feature, so under `--all-features` the methods compiled in without reachable callers and clippy flagged them as dead code. Narrow the gate to `cfg(test)` and drop the vestigial feature entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-config/Cargo.toml | 1 - lib/crates/fabro-config/src/layers/settings.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index 1a6b786b0..65252066c 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -12,7 +12,6 @@ doctest = false [features] default = [] clap = ["dep:clap", "fabro-types/clap"] -test-support = [] [lints] workspace = true diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs index 453ea7a35..4d3401c79 100644 --- a/lib/crates/fabro-config/src/layers/settings.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -98,7 +98,7 @@ impl From for SettingsLayer { } } -#[cfg(any(test, feature = "test-support"))] +#[cfg(test)] impl SettingsLayer { /// A default layer that resolves cleanly: populates `server.auth.methods` /// with `["dev-token"]`. Use anywhere a test needs a starter From e65d9a92e276c4fbf362ac536376206fe30a0450 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Apr 2026 22:45:07 -0400 Subject: [PATCH 60/60] fix sleep_inhibitor lints under --all-features clippy The `sleep_inhibitor` feature pulled in 20 pedantic/nightly lints that CI (default features) never exercised. Narrow all `pub` items in the module to `pub(crate)`/`pub(super)`, replace the `use super::iokit_bindings::*` wildcard with explicit imports, use `&raw mut` for FFI pointer borrows, drop the always-`Some` wrapping in `DummySleepInhibitor::acquire`, and bring `crate::sleep_inhibitor` into scope at the three call sites so they don't trip `clippy::absolute_paths`. Verified: `cargo +nightly-2026-04-14 clippy --workspace --all-targets --all-features -- -D warnings` clean, `cargo nextest run --workspace --all-features` 4563 tests passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-cli/src/commands/exec.rs | 4 +++- .../fabro-cli/src/commands/run/command.rs | 4 +++- lib/crates/fabro-cli/src/commands/run/mod.rs | 4 +++- .../fabro-cli/src/sleep_inhibitor/dummy.rs | 4 ++-- .../src/sleep_inhibitor/iokit_bindings.rs | 24 +++++++++---------- .../fabro-cli/src/sleep_inhibitor/macos.rs | 7 ++++-- .../fabro-cli/src/sleep_inhibitor/mod.rs | 9 ++++--- 7 files changed, 32 insertions(+), 24 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 78578838f..1404767a4 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -21,6 +21,8 @@ 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}; struct AuthenticatedFabroServerAdapter { @@ -277,7 +279,7 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu let cli = &ctx.user_settings().cli; #[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 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/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/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()), }) }