diff --git a/Cargo.lock b/Cargo.lock index 233aa5c14..8d1b03b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1541,9 +1541,10 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", + "fabro-config", "fabro-types", "openapiv3", "prettyplease", @@ -1560,7 +1561,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1581,7 +1582,7 @@ dependencies = [ [[package]] name = "fabro-checkpoint" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "fabro-store", @@ -1596,7 +1597,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1686,7 +1687,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1713,7 +1714,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1726,6 +1727,7 @@ dependencies = [ "serde", "serde_json", "strsim 0.11.1", + "temp-env", "tempfile", "thiserror 2.0.18", "toml 0.8.23", @@ -1735,7 +1737,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1750,7 +1752,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-http", "fabro-util", @@ -1766,7 +1768,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "base64", "chrono", @@ -1782,7 +1784,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1796,7 +1798,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1819,7 +1821,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "http", "reqwest 0.13.2", @@ -1828,7 +1830,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1842,7 +1844,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -1856,7 +1858,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1886,7 +1888,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "proc-macro2", "quote", @@ -1895,7 +1897,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -1911,7 +1913,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "insta", "serde", @@ -1921,7 +1923,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "axum", "base64", @@ -1940,7 +1942,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "cc", "libc", @@ -1949,7 +1951,7 @@ dependencies = [ [[package]] name = "fabro-retro" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1967,7 +1969,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1999,7 +2001,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2050,6 +2052,7 @@ dependencies = [ "percent-encoding", "rand 0.9.4", "regex", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -2076,7 +2079,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2095,14 +2098,14 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-store" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2128,7 +2131,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2153,7 +2156,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2165,7 +2168,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2187,7 +2190,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-github", @@ -2200,7 +2203,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2221,7 +2224,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "aho-corasick", "anyhow", @@ -2243,7 +2246,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2253,7 +2256,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "serde", @@ -2264,7 +2267,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -6421,6 +6424,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -6900,7 +6912,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "axum", "base64", @@ -6919,7 +6931,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 49ad412a2..a4f711760 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/apps/fabro-web/app/lib/workflow-api.ts b/apps/fabro-web/app/lib/workflow-api.ts index e73d79ed0..6ee4ecb97 100644 --- a/apps/fabro-web/app/lib/workflow-api.ts +++ b/apps/fabro-web/app/lib/workflow-api.ts @@ -1,12 +1,10 @@ import type { PaginationMeta } from "@qltysh/fabro-api-client"; /** - * Opaque settings payload returned by `/api/v1/runs/:id/settings`. Mirrors the - * v2 `SettingsFile` shape in `lib/crates/fabro-types/src/settings/tree.rs`, - * with secret-bearing subtrees dropped before serialization. Treated as a - * loose JSON object on the web side — consumers only render it. + * 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. */ -export type RunSettings = Record; +export type RunSettingsLayer = Record; export interface WorkflowScheduleSummary { expression: string; @@ -35,6 +33,6 @@ export interface WorkflowDetailResponse { slug: string; description: string; filename: string; - settings: RunSettings; + settings: RunSettingsLayer; graph: string; } diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index 9bfa09f99..e66f4bbeb 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 { RunSettings } from "../lib/workflow-api"; +import type { RunSettingsLayer } from "../lib/workflow-api"; export const handle = { wide: true }; export async function loader({ request, params }: any) { const [{ data: apiStages }, settings] = await Promise.all([ apiJson(`/runs/${params.id}/stages`, { request }), - apiJson(`/runs/${params.id}/settings`, { request }), + apiJson(`/runs/${params.id}/settings`, { request }), ]); const stages: Stage[] = apiStages.filter((s) => isVisibleStage(s.id)).map((s) => ({ id: s.id, diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index e557d622a..dc5cb53ce 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -1,13 +1,7 @@ +import type { ServerSettings } from "@qltysh/fabro-api-client"; import { apiJson } from "../api"; import { CollapsibleFile } from "../components/collapsible-file"; -/** - * Opaque server settings payload returned by `/api/v1/settings`. Mirrors the - * v2 `SettingsFile` shape with secret-bearing subtrees dropped before - * serialization. The UI only renders it as JSON. - */ -type ServerSettings = Record; - export function meta({}: any) { return [{ title: "Settings — Fabro" }]; } diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index f7951413e..0999ef984 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -1,22 +1,24 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { Link, Outlet, useLocation, useParams } from "react-router"; import { apiJsonOrNull } from "../api"; -import type { RunSettings, WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api"; +import type { + RunSettingsLayer, + WorkflowDetailResponse as ApiWorkflowDetail, +} from "../lib/workflow-api"; export interface WorkflowEntry { name: string; slug: string; description: string; filename: string; - settings: RunSettings; + settings: RunSettingsLayer; graph: string; } // Static sample data used by the `workflow-definition` index route for the -// hardcoded showcase workflows. Shape mirrors the v2 `SettingsFile` JSON -// returned by `/api/v1/runs/:id/settings` (see the Rust -// `fabro_types::settings::SettingsFile` type). Fields are opaque to the -// `RunSettings` TypeScript type, which is a bare `Record`. +// 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`. export const workflowData: Record = { fix_build: { name: "Fix Build", diff --git a/bin/dev/check-boundary.sh b/bin/dev/check-boundary.sh index d1cfb3f50..974dc4442 100755 --- a/bin/dev/check-boundary.sh +++ b/bin/dev/check-boundary.sh @@ -3,9 +3,16 @@ set -euo pipefail cd "$(dirname "$0")/../.." -symbol_allowlist=( +server_symbol_allowlist=( "lib/crates/fabro-cli/src/local_server.rs" + "lib/crates/fabro-cli/src/commands/run/runner.rs" + "lib/crates/fabro-cli/src/commands/pr/mod.rs" + "lib/crates/fabro-cli/src/commands/pr/create.rs" +) + +storage_allowlist=( "lib/crates/fabro-cli/src/commands/install.rs" + "lib/crates/fabro-cli/src/commands/uninstall.rs" "lib/crates/fabro-cli/src/commands/run/runner.rs" "lib/crates/fabro-cli/src/commands/pr/mod.rs" "lib/crates/fabro-cli/src/commands/pr/create.rs" @@ -49,11 +56,19 @@ fail=0 while IFS= read -r path; do [[ -z "$path" ]] && continue - if ! in_array "$path" "${symbol_allowlist[@]}"; then + if ! in_array "$path" "${server_symbol_allowlist[@]}"; then echo "boundary check failed: gated server symbol used outside allowlist: $path" >&2 fail=1 fi -done < <(find_matches 'fabro_config::resolve_server_from_file|fabro_config::resolve_server\b|Storage::new') +done < <(find_matches 'fabro_config::resolve_server_from_file|fabro_config::resolve_server\b|fabro_config::ServerSettings::from_layer\b|fabro_config::ServerSettings::resolve\b|ServerSettings::from_layer\b|ServerSettings::resolve\b') + +while IFS= read -r path; do + [[ -z "$path" ]] && continue + if ! in_array "$path" "${storage_allowlist[@]}"; then + echo "boundary check failed: Storage::new used outside allowlist: $path" >&2 + fail=1 + fi +done < <(find_matches 'Storage::new') while IFS= read -r path; do [[ -z "$path" ]] && continue diff --git a/bin/dev/check-env-mutation.sh b/bin/dev/check-env-mutation.sh index 5387a0052..6b95cb8f7 100755 --- a/bin/dev/check-env-mutation.sh +++ b/bin/dev/check-env-mutation.sh @@ -20,7 +20,8 @@ while IFS= read -r match; do case "$path:$line" in "lib/crates/fabro-telemetry/src/spawn.rs:std::env::set_var(key, value);" | \ - "lib/crates/fabro-telemetry/src/spawn.rs:std::env::remove_var(key);") + "lib/crates/fabro-telemetry/src/spawn.rs:std::env::remove_var(key);" | \ + 'lib/crates/fabro-server/src/install.rs:std::env::set_var("FABRO_TEST_IN_MEMORY_STORE", "1");') continue ;; esac diff --git a/docs-internal/cli-workflow-coupling-audit.md b/docs-internal/cli-workflow-coupling-audit.md index 00f9c295b..a58c2c828 100644 --- a/docs-internal/cli-workflow-coupling-audit.md +++ b/docs-internal/cli-workflow-coupling-audit.md @@ -34,7 +34,7 @@ | Path | Direct dependency | Why it still exists | Suggested handling | | --- | --- | --- | --- | -| `lib/crates/fabro-cli/src/commands/store/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | +| `lib/crates/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | | `lib/crates/fabro-cli/src/commands/run/wait.rs` test module | `outcome::StageStatus`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once status/conclusion DTOs move out. | | `lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. | | `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. | diff --git a/docs-internal/run-directory-keys.md b/docs-internal/run-directory-keys.md index 4d5adc8e7..990e57bfe 100644 --- a/docs-internal/run-directory-keys.md +++ b/docs-internal/run-directory-keys.md @@ -33,7 +33,7 @@ These paths are local runtime state, not canonical event projections. These names are still real, but they are no longer live scratch files by default: - Metadata branch files such as `run.json`, `start.json`, `checkpoint.json`, and `retro.json` -- `fabro store dump` exports such as `run.json`, `start.json`, `status.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, `events.jsonl`, and per-node prompt/response/status/stdout/stderr files +- `fabro dump` exports such as `run.json`, `start.json`, `status.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, `events.jsonl`, and per-node prompt/response/status/stdout/stderr files - Retro-agent temp uploads named `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json` inside the retro sandbox ## Notes diff --git a/docs/agents/outputs.mdx b/docs/agents/outputs.mdx index e375ff405..3e95af24d 100644 --- a/docs/agents/outputs.mdx +++ b/docs/agents/outputs.mdx @@ -7,7 +7,7 @@ When an agent or prompt node finishes, Fabro captures its response text and prod ## Response capture -After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{node_id}@{visit}/response.md` in metadata snapshots and `fabro store dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{node_id}@{visit}/status.json`. +After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{node_id}@{visit}/response.md` in metadata snapshots and `fabro dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{node_id}@{visit}/status.json`. ## Context updates @@ -92,7 +92,7 @@ review -> approve [label="Approve"] ## Output logging -Fabro writes several files per stage to `stages/{node_id}@{visit}/` in metadata snapshots and `fabro store dump` output: +Fabro writes several files per stage to `stages/{node_id}@{visit}/` in metadata snapshots and `fabro dump` output: | File | Contents | |---|---| diff --git a/docs/agents/prompts.mdx b/docs/agents/prompts.mdx index 69404aa79..61cc58dc1 100644 --- a/docs/agents/prompts.mdx +++ b/docs/agents/prompts.mdx @@ -295,4 +295,4 @@ Use prompt nodes for analysis, classification, and summarization tasks where too ## Prompt logging -Fabro persists the assembled prompt to `stages/{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro store dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly. +Fabro persists the assembled prompt to `stages/{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly. diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index ec06dcdd8..bf2392b5d 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 structured settings used to launch this run. + description: Returns the persisted `SettingsLayer` used to launch this run. parameters: - $ref: "#/components/parameters/RunId" responses: @@ -1385,7 +1385,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RunSettings" + $ref: "#/components/schemas/RunSettingsLayer" "404": description: Run not found content: @@ -2012,22 +2012,11 @@ paths: tags: [Settings] summary: Retrieve Server Settings description: > - Returns the server settings view selected by the optional `view` query - parameter. `view=layer` (the default) returns the current sparse - redacted `SettingsLayer` payload. `view=resolved` returns the server's - dense resolved settings payload after applying the same redaction - policy. - parameters: - - $ref: "#/components/parameters/SettingsView" + Returns the server's current in-memory settings view as the typed + `ServerSettings` payload. responses: "200": description: Server settings - headers: - X-Fabro-Settings-View: - description: Present with value `resolved` when the response body is the dense resolved settings view. - schema: - type: string - enum: [resolved] content: application/json: schema: @@ -2069,16 +2058,6 @@ components: type: string example: nightly-build - SettingsView: - name: view - in: query - required: false - description: Selects the server settings representation to return. - schema: - type: string - enum: [layer, resolved] - default: layer - StageId: name: stageId in: path @@ -5148,32 +5127,328 @@ components: # ── Settings Schemas ───────────────────────────────────────────────── ServerSettings: - description: | - Redacted server settings payload. - - The `/api/v1/settings` endpoint supports two response shapes: - - - `view=layer` (default): the sparse redacted `SettingsLayer` shape - - `view=resolved`: the dense resolved `Settings` shape - - Both views drop the same exact operational path: - - - `server.listen` - - For non-redacted `InterpString` fields, the wire payload preserves the - unresolved source/template string rather than any environment-resolved - secret value. + description: Current in-memory server settings view. type: object - additionalProperties: true + required: [server, features] + properties: + server: + $ref: "#/components/schemas/ServerNamespace" + features: + $ref: "#/components/schemas/FeaturesNamespace" - RunSettings: + ServerNamespace: + type: object + required: + - listen + - api + - web + - auth + - ip_allowlist + - storage + - artifacts + - slatedb + - scheduler + - logging + - integrations + properties: + listen: + $ref: "#/components/schemas/ServerListenSettings" + api: + $ref: "#/components/schemas/ServerApiSettings" + web: + $ref: "#/components/schemas/ServerWebSettings" + auth: + $ref: "#/components/schemas/ServerAuthSettings" + ip_allowlist: + $ref: "#/components/schemas/ServerIpAllowlistSettings" + storage: + $ref: "#/components/schemas/ServerStorageSettings" + artifacts: + $ref: "#/components/schemas/ServerArtifactsSettings" + slatedb: + $ref: "#/components/schemas/ServerSlateDbSettings" + scheduler: + $ref: "#/components/schemas/ServerSchedulerSettings" + logging: + $ref: "#/components/schemas/ServerLoggingSettings" + integrations: + $ref: "#/components/schemas/ServerIntegrationsSettings" + + FeaturesNamespace: + type: object + required: [session_sandboxes] + properties: + session_sandboxes: + type: boolean + + ServerListenSettings: + oneOf: + - $ref: "#/components/schemas/ServerListenTcpSettings" + - $ref: "#/components/schemas/ServerListenUnixSettings" + + ServerListenTcpSettings: + type: object + required: [type, address] + properties: + type: + type: string + enum: [tcp] + address: + type: string + + ServerListenUnixSettings: + type: object + required: [type, path] + properties: + type: + type: string + enum: [unix] + path: + type: string + + ServerApiSettings: + type: object + required: [url] + properties: + url: + type: ["string", "null"] + + ServerWebSettings: + type: object + required: [enabled, url] + properties: + enabled: + type: boolean + url: + type: string + + ServerAuthSettings: + type: object + required: [methods, github] + properties: + methods: + type: array + items: + $ref: "#/components/schemas/ServerAuthMethod" + github: + $ref: "#/components/schemas/ServerAuthGithubSettings" + + ServerAuthMethod: + type: string + enum: [dev-token, github] + + ServerAuthGithubSettings: + type: object + required: [allowed_usernames] + properties: + allowed_usernames: + type: array + items: + type: string + + ServerIpAllowlistSettings: + type: object + required: [entries, trusted_proxy_count] + properties: + entries: + type: array + items: + $ref: "#/components/schemas/IpAllowEntry" + trusted_proxy_count: + type: integer + + ServerIpAllowlistOverrideSettings: + type: object + required: [entries, trusted_proxy_count] + properties: + entries: + type: ["array", "null"] + items: + $ref: "#/components/schemas/IpAllowEntry" + trusted_proxy_count: + type: ["integer", "null"] + + IpAllowEntry: + oneOf: + - $ref: "#/components/schemas/LiteralIpAllowEntry" + - $ref: "#/components/schemas/GitHubMetaHooksEntry" + + LiteralIpAllowEntry: + type: object + required: [Literal] + properties: + Literal: + type: string + + GitHubMetaHooksEntry: + type: string + enum: [GitHubMetaHooks] + + ServerStorageSettings: + type: object + required: [root] + properties: + root: + type: string + + ServerArtifactsSettings: + type: object + required: [prefix, store] + properties: + prefix: + type: string + store: + $ref: "#/components/schemas/ObjectStoreSettings" + + ServerSlateDbSettings: + type: object + required: [prefix, store, flush_interval, disk_cache] + properties: + prefix: + type: string + store: + $ref: "#/components/schemas/ObjectStoreSettings" + flush_interval: + type: string + disk_cache: + type: boolean + + ObjectStoreSettings: + oneOf: + - $ref: "#/components/schemas/ObjectStoreLocalSettings" + - $ref: "#/components/schemas/ObjectStoreS3Settings" + + ObjectStoreLocalSettings: + type: object + required: [type, root] + properties: + type: + type: string + enum: [local] + root: + type: string + + ObjectStoreS3Settings: + type: object + required: [type, bucket, region, endpoint, path_style] + properties: + type: + type: string + enum: [s3] + bucket: + type: string + region: + type: string + endpoint: + type: ["string", "null"] + path_style: + type: boolean + + ServerSchedulerSettings: + type: object + required: [max_concurrent_runs] + properties: + max_concurrent_runs: + type: integer + + ServerLoggingSettings: + type: object + required: [level] + properties: + level: + type: ["string", "null"] + + ServerIntegrationsSettings: + type: object + required: [github, slack, discord, teams] + properties: + github: + $ref: "#/components/schemas/GithubIntegrationSettings" + slack: + $ref: "#/components/schemas/SlackIntegrationSettings" + discord: + $ref: "#/components/schemas/DiscordIntegrationSettings" + teams: + $ref: "#/components/schemas/TeamsIntegrationSettings" + + GithubIntegrationSettings: + type: object + required: + - enabled + - strategy + - app_id + - client_id + - slug + - permissions + - webhooks + properties: + enabled: + type: boolean + strategy: + $ref: "#/components/schemas/GithubIntegrationStrategy" + app_id: + type: ["string", "null"] + client_id: + type: ["string", "null"] + slug: + type: ["string", "null"] + permissions: + type: object + additionalProperties: + type: string + webhooks: + oneOf: + - $ref: "#/components/schemas/IntegrationWebhooksSettings" + - type: "null" + + GithubIntegrationStrategy: + type: string + enum: [token, app] + + SlackIntegrationSettings: + type: object + required: [enabled, default_channel] + properties: + enabled: + type: boolean + default_channel: + type: ["string", "null"] + + DiscordIntegrationSettings: + type: object + required: [enabled] + properties: + enabled: + type: boolean + + TeamsIntegrationSettings: + type: object + required: [enabled] + properties: + enabled: + type: boolean + + IntegrationWebhooksSettings: + type: object + required: [strategy, ip_allowlist] + properties: + strategy: + oneOf: + - $ref: "#/components/schemas/WebhookStrategy" + - type: "null" + ip_allowlist: + oneOf: + - $ref: "#/components/schemas/ServerIpAllowlistOverrideSettings" + - type: "null" + + WebhookStrategy: + type: string + enum: [tailscale_funnel, server_url] + + RunSettingsLayer: description: | - The merged, persisted v2 `[run]` subtree for a specific run, serialized - as the wrapping `SettingsFile` shape (so `settings.run.*` holds the run - config). Matches `fabro_types::settings::SettingsFile` minus secret - subtrees, identical to ServerSettings' redaction rules. - - See `lib/crates/fabro-types/src/settings/run.rs` for the full type. + The persisted `SettingsLayer` used for a specific run, serialized as-is. + This matches the stored run manifest shape rather than a resolved view. type: object additionalProperties: true diff --git a/docs/changelog/2026-03-29.mdx b/docs/changelog/2026-03-29.mdx index e6e82bdcf..f89bbadab 100644 --- a/docs/changelog/2026-03-29.mdx +++ b/docs/changelog/2026-03-29.mdx @@ -1,14 +1,14 @@ --- -title: "Store dump export command" +title: "Dump export command" date: "2026-03-29" --- -## `fabro store dump` +## `fabro dump` -A new `fabro store dump` command exports the contents of the run store to a human-readable format for debugging and inspection. This is useful for diagnosing issues with run state, verifying data integrity after migrations, or extracting run data for external analysis. +A new `fabro dump` command exports the contents of the run store to a human-readable format for debugging and inspection. This is useful for diagnosing issues with run state, verifying data integrity after migrations, or extracting run data for external analysis. ```bash -fabro store dump +fabro dump ``` ## More diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 80eff8ed8..d10a5ec91 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -81,7 +81,7 @@ jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.la <(fabro logs 01JKXYZ...) | head ``` -If you need files on disk for offline analysis, `fabro store dump` exports `events.jsonl` plus run-state projections. +If you need files on disk for offline analysis, `fabro dump` exports `events.jsonl` plus run-state projections. ## Event categories @@ -132,6 +132,6 @@ Post-run analysis surfaces include: |---|---| | `fabro logs ` | Full event envelope stream as NDJSON | | `fabro inspect ` | Current durable run state, including run/start/checkpoint/conclusion records | -| `fabro store dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | +| `fabro dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | See [retros](/execution/retros), [stages](/api-reference/run-internals/list-run-stages), and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. diff --git a/docs/execution/retros.mdx b/docs/execution/retros.mdx index 879190803..e5a8437f4 100644 --- a/docs/execution/retros.mdx +++ b/docs/execution/retros.mdx @@ -143,4 +143,4 @@ Retros are also available via the REST API. See the [list retros](/api-reference ## Storage -Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes retro text under `stages/retro/` alongside `run.json`, stage files, and the rest of the exported run data. +Retros are stored in durable run state. If you need files on disk, `fabro dump` materializes retro text under `stages/retro/` alongside `run.json`, stage files, and the rest of the exported run data. 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 85c26ade8..c50b78300 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 @@ -9,338 +9,362 @@ date: 2026-04-22 ## Overview -Replace the current free-function / layer-passing settings API with three -owner-first context types that expose `::resolve*()` constructors. Callers ask -for exactly the settings their context owns, and the merge/layer machinery -becomes an internal concern of `fabro-config`. +Replace the free-function settings API with two owner-first context types +that expose dense, resolved views of current config. The elegance rule: -Today, reading resolved settings means composing free functions -(`fabro_config::resolve_server_from_file`, `resolve_cli_from_file`, etc.) over -a sparse `SettingsLayer`, driven by a three-variant `EffectiveSettingsMode` -enum (`LocalOnly`, `RemoteServer`, `LocalDaemon`). Two of those variants are -effectively dead: `RemoteServer` is only reached by test helpers, and -`LocalOnly` is only reached by `fabro settings --local`. A single god type -`Settings` carries all six namespaces, and callers freely reach into namespaces -they don't own. +- **`SettingsLayer`** is the sparse transport/storage form — what TOML + files parse into, what's persisted in run manifests, what merges with + precedence. +- **Context types** (`ServerSettings`, `UserSettings`) are dense, + owner-scoped, resolved views of a *current process's* config, + computed from a layer at the moment a consumer needs them. + +These roles do not overlap: stored artifacts are layers; current-config +reads are views. Stored-layer readers (code that reads specific +namespaces off a persisted run's `SettingsLayer`) keep using +per-namespace resolvers — the layer is the real artifact there. After this refactor: -- **`ServerSettings`** — `server` + `features` namespaces. Resolved by the - server at startup; returned by `GET /api/v1/settings`. -- **`UserSettings`** — `cli` + `features` namespaces. Resolved by the CLI - process from its local `~/.fabro/settings.toml`. -- **`WorkflowSettings`** — all six namespaces (`server`, `project`, `workflow`, - `run`, `cli`, `features`). Resolved server-side when a submitted run is - materialized. Replaces today's `Settings` god type. +- **`ServerSettings`** — `server` + `features` namespaces. Derived from + the server's **effective runtime layer** (the post-`apply_runtime_settings` + `SettingsLayer` that folds in CLI overrides like `--storage-dir` and + `--bind`) at startup and whenever hot-reload refreshes the layer. + Held in `AppState` alongside the shared layer itself. + `GET /api/v1/settings` serves the current in-memory view directly — + no per-request disk read, no view toggle, no redaction. +- **`UserSettings`** — `cli` + `features` namespaces. The CLI process + builds one from its own `~/.fabro/settings.toml`. `fabro run attach` + uses the *live* `UserSettings` at attach time. -Each type gets an `::resolve*()` constructor; each hides the `SettingsLayer` -plumbing. The mode enum disappears, the `--local` diagnostic path is deleted, -and `materialize_settings_layer` becomes a single straight-line function -behind `WorkflowSettings::resolve_for_run`. +Both context types expose `from_layer(&SettingsLayer)` (primitive) and +`resolve()` (convenience that loads the default file; used by tools +and tests, not the server startup path which composes `from_layer` +against the effective runtime layer). + +The god type `Settings` goes away with no named replacement — its +former consumers either migrate to per-namespace resolvers (the +stored-layer readers) or are deleted wholesale (the view-toggle +machinery removed by Unit 7). `EffectiveSettingsMode` goes away; +`fabro settings --local` goes away; redaction machinery goes away. + +Settings contain no secrets — `InterpString` templates preserve +`{{ env.NAME }}` unresolved on the wire. Actual secrets live in +`ServerSecrets` / Vault. ## Problem Frame -The settings API grew around a layered-config design (`SettingsLayer` + -free-function resolvers + mode enum) that is now visibly clunky. Symptoms: +The settings API grew around a layered-config design that's now visibly +clunky: -- Every caller that wants a resolved value first needs a `SettingsLayer`, then - picks the right `resolve_*_from_file` helper. The layer is exposed in the - public API even though few callers should care. -- The `EffectiveSettingsMode` enum has three variants encoding - client/server trust rules, but today's production code path always picks - `LocalDaemon`. `RemoteServer` is test-only (a vestige of the removed - "CLI executes workflows directly" flow), and `LocalOnly` exists only to - back `fabro settings --local`. -- The god type `fabro_types::settings::Settings` carries all six namespaces - indiscriminately. A server-side path that needs `run.*` also gets `cli.*` - in the same struct, obscuring ownership. -- A recent CLI/server boundary cleanup (commit `5b1c40764`) moved all - `[server.*]` reads into `fabro_cli::local_server` so general CLI commands - couldn't accidentally reach them. That boundary is preserved today by - convention + a shell script (`bin/dev/check-boundary.sh`). Type-level - projection — where a CLI command that holds a `UserSettings` *cannot* - read `server.*` at all — is a stronger form of the same invariant. +- Every caller that wants a resolved value first takes a `SettingsLayer`, + then picks the right `resolve_*_from_file` helper. The layer plumbing is + on the public surface even though most callers just want "give me the + server config." +- `EffectiveSettingsMode` has three variants; production code always picks + `LocalDaemon`. The enum is dead ceremony. +- `fabro_types::settings::Settings` is a god type — server-side execution + code needing `run.*` also gets `cli.*` in the same struct. +- The CLI/server trust boundary is enforced by `fabro_cli::local_server` + convention plus `bin/dev/check-boundary.sh`. Type-level projection does + this at compile time. -The settings schema itself is in good shape (see origin-adjacent context -below). This plan is the programmatic API layer over that schema. +The settings schema itself is fine. This plan is the programmatic API over +it. ## Requirements Trace -- **R1.** Outside code calls `ServerSettings::resolve()`, - `UserSettings::resolve()`, or `WorkflowSettings::resolve_for_run(...)` — no - caller outside `fabro-config` constructs a `SettingsLayer` or calls - `resolve_*_from_file` for normal resolution. -- **R2.** Each context type exposes only the namespaces it owns (strict - projection): `ServerSettings` has `server` + `features`; `UserSettings` - has `cli` + `features`; `WorkflowSettings` has all six. -- **R3.** The `EffectiveSettingsMode` enum is deleted. The merge code path - that survives is the current `LocalDaemon` behavior (strip owner domains - from project/workflow; server is authoritative for server-owned fields). -- **R4.** The `fabro settings --local` flag and all supporting CLI-side - filesystem-walking layer assembly is removed. `fabro settings` always - talks to the server. -- **R5.** `GET /api/v1/settings` returns a dense `ServerSettings` payload - (no `view=layer|resolved` toggle, no `X-Fabro-Settings-View` header). - The OpenAPI schema describes the dense shape properly, not - `additionalProperties: true`. +- **R1.** Current-config callers (the server's in-memory settings, the + CLI process's settings) use the context types. Stored-layer readers — + code that reads specific namespaces off a persisted `SettingsLayer` + (`runner.rs`, `operations/create.rs`) — continue to use per-namespace + resolvers. The layer is the real artifact there and must not be + contorted to fit the context-type API. +- **R2.** Each context type exposes namespaces owned by its consumer, + plus the cross-cutting `features.*` namespace. +- **R3.** `EffectiveSettingsMode` is deleted. One merge path remains. +- **R4.** `fabro settings --local` and its filesystem-walking assembly + are deleted. +- **R5.** `GET /api/v1/settings` returns the server's in-memory + `ServerSettings` as a typed JSON body. No `view=` query param, no + `X-Fabro-Settings-View` header, no disk re-read, no redaction. - **R6.** Today's per-namespace resolved types (`ServerSettings`, `CliSettings`, `ProjectSettings`, `WorkflowSettings`, `RunSettings`, - `FeaturesSettings`) are renamed with a `*Namespace` suffix, freeing the - shorter names for the new context types. -- **R7.** Today's god type `fabro_types::settings::Settings` is deleted; - callers migrate to `WorkflowSettings`, which has the same field set. -- **R8.** `materialize_settings_layer`, `EffectiveSettingsLayers`'s - construction path, and `user::load_settings_config` are no longer part of - the public API of `fabro-config`. (`EffectiveSettingsLayers` the struct - may remain `pub` as the input to `WorkflowSettings::resolve_for_run`, - since only `fabro-server/run_manifest.rs` builds it.) -- **R9.** The CLI/server boundary enforced today by - `fabro_cli::local_server` + `bin/dev/check-boundary.sh` stays intact or - strengthens. In particular, CLI commands that don't need - `server.*` never hold a `ServerSettings` — they hold `UserSettings` - instead. + `FeaturesSettings`) rename with a `*Namespace` suffix, freeing the + short names for context types. Only `ServerSettings` and `UserSettings` + are defined as context types in this PR. +- **R7.** The `Settings` god type (and the public `fabro_config::resolve` + function that returns it, and the `load_and_resolve` helper that + wraps them) are deleted. No named replacement type is introduced; + consumers migrate to per-namespace resolvers or disappear with the + view-toggle machinery Unit 7 removes. +- **R8.** Layer-merge internals are `pub(crate)` or private. + Per-namespace resolver visibility matches reality (see Key Technical + Decisions). +- **R9.** The CLI/server trust boundary survives. `fabro_cli::local_server` + remains the only sanctioned CLI-side gateway to `[server.*]`; + `bin/dev/check-boundary.sh` updates to cover the new symbols. ## Scope Boundaries - **Not changing** the TOML schema, namespace inventory, merge precedence, - or strip-owner-domains trust rules. The *rules* survive; only the API that - exposes them changes. -- **Not changing** the wire format of persisted run settings (still a - `SettingsLayer` serialized into the run spec). -- **Not adding** new endpoints or new CLI commands. Only the response shape - of `GET /api/v1/settings` changes. -- **Not reworking** the CLI/server boundary enforcement script beyond the - mechanical updates that follow from renames. -- **Not relocating** the `fabro_cli::local_server` module. Its three - boundary helpers (`storage_dir`, `bind_request`, `auth_methods`) continue - to be the sanctioned entry points from CLI lifecycle commands into - `[server.*]`. -- **Out of scope:** any UX redesign of `fabro settings` output beyond the - minimal change required by the narrower server response (see Unit 7). + or owner-domain stripping rules. +- **Not changing** `PreparedManifest.settings` — stays `SettingsLayer`. + Wire format for persisted run settings unchanged. +- **Not migrating** stored-layer readers. `runner.rs:507-508` + (`resolve_run_from_file` / `resolve_server_from_file` on `record.settings`) + and `operations/create.rs` (metadata aggregation from stored layers) are + legitimate consumers of the sparse layer, not candidates for + context-type migration. +- **Not adding** endpoints or CLI commands. +- **Settings contain no secrets** (invariant, with documented gaps). + Secret-bearing fields should be `InterpString`. Known gaps not addressed + here: `McpTransport::Http.headers`, `McpTransport::Stdio.env`, + `McpTransport::Sandbox.env`, `HookType::Http.headers`, `run.inputs`, + `run.metadata`. Pre-existing; deferred to a follow-up plan that retypes + the maps to `HashMap` and adds submit-time + validation. Any new field added by this refactor must satisfy the + invariant. ## Context & Research ### Relevant Code and Patterns -- `lib/crates/fabro-config/src/effective_settings.rs` — layer merge, mode - enum, owner-domain stripping, server-authoritative overrides. -- `lib/crates/fabro-config/src/resolve/` — per-namespace resolve helpers - (`resolve_server_from_file` and siblings). -- `lib/crates/fabro-config/src/user.rs` — loads `~/.fabro/settings.toml` - into a `SettingsLayer`; exposes `default_settings_path()` via - `fabro_util::home::Home::from_env()`. -- `lib/crates/fabro-types/src/settings/resolved.rs` — the god type - `Settings` (six-field struct). -- `lib/crates/fabro-types/src/settings/mod.rs` — re-exports the - per-namespace resolved types that will be renamed. -- `lib/crates/fabro-cli/src/local_server.rs` — the single sanctioned CLI - entry point into `[server.*]`. Today's example of the owner-first - boundary pattern we're lifting to the type system. -- `lib/crates/fabro-cli/src/commands/config/mod.rs` — current - `fabro settings` command; contains the `--local` branch and the - filesystem-walking layer assembly that will be deleted. -- `lib/crates/fabro-server/src/run_manifest.rs` — the sole production - caller of `materialize_settings_layer`; always picks `LocalDaemon`. -- `lib/crates/fabro-server/src/settings_view.rs` — redacts `Settings` for - API responses. Signature retypes as part of the god-type removal. -- `docs/api-reference/fabro-api.yaml` — OpenAPI spec. `GET /api/v1/settings` - at line 1947; `ServerSettings` schema at line 4897 (currently - `additionalProperties: true`, with `view=layer|resolved` query param). - Rust types regenerate via `lib/crates/fabro-api/build.rs`; TypeScript - client regenerates via `cd lib/packages/fabro-api-client && bun run - generate`. -- `lib/crates/fabro-server/tests/it/openapi_conformance.rs` — conformance - test that catches spec/router drift; the refactor must keep this green. -- `bin/dev/check-boundary.sh` — regression guard for the - `fabro_cli::local_server` boundary; needs a pass after the renames to - confirm the grep patterns still work. +- `lib/crates/fabro-config/src/effective_settings.rs` — layer merge + + owner-domain stripping. +- `lib/crates/fabro-config/src/resolve/` — per-namespace resolve helpers. +- `lib/crates/fabro-config/src/user.rs` — loads + `~/.fabro/settings.toml` into a `SettingsLayer`. +- `lib/crates/fabro-types/src/settings/resolved.rs` — god type `Settings`. +- `lib/crates/fabro-types/src/settings/mod.rs` — re-exports per-namespace + types to be renamed. +- `lib/crates/fabro-cli/src/local_server.rs` — single sanctioned CLI + gateway to `[server.*]`. +- `lib/crates/fabro-cli/src/commands/config/mod.rs` — `fabro settings` + command; contains `--local` branch to delete. +- `lib/crates/fabro-server/src/run_manifest.rs` — sole production caller + of `materialize_settings_layer`. +- `lib/crates/fabro-server/src/settings_view.rs` — deleted by this + refactor. +- `lib/crates/fabro-server/src/server.rs` — `AppState` will hold the + server's `ServerSettings`; the settings endpoint handler serves it + directly. +- `docs/api-reference/fabro-api.yaml` — OpenAPI spec. +- `bin/dev/check-boundary.sh` — CLI/server boundary regression guard. ### Related Context -- `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` - defined the six-namespace schema (R3), owner-first trust boundaries (R16), - and the paste-anywhere / strict-namespacing rules this refactor now - operationalizes in code. Not a strict origin for this plan — it defined - the file schema; this plan defines the programmatic API over it — but - the motivation is directly downstream of R16. -- Recent cleanup commit `7cb6c65d5 Remove dev-token minting from server - start` and `77fc77872 Gate dev-token handling on explicit auth methods` - are the most recent touches in this area; they didn't change the settings - API shape. +- `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` — + defined the six-namespace schema and owner-first trust boundaries. This + plan operationalizes those boundaries in code. ### Call-Site Inventory -To size the migration: - - `resolve_server_from_file` outside `fabro-config`: ~20 call sites across - `fabro-cli` (`local_server.rs`, `commands/exec.rs`, `commands/install.rs`, - `commands/pr/mod.rs`, `commands/run/attach.rs`, `commands/run/runner.rs`), - `fabro-server` (`serve.rs`, `run_manifest.rs`, `install.rs`, `server.rs`, - `jwt_auth.rs`), and `fabro-install/src/lib.rs`. -- `resolve_cli_from_file` outside `fabro-config`: 3 call sites — - `fabro-cli/src/user_config.rs`, `fabro-cli/src/commands/run/attach.rs`, - `fabro-cli/src/commands/config/mod.rs` (`--local` path, will be deleted). -- `materialize_settings_layer`: 3 call sites — - `fabro-config/src/lib.rs` (internal `load_and_resolve`), - `fabro-cli/src/commands/config/mod.rs` (`--local`, will be deleted), - `fabro-server/src/run_manifest.rs` (becomes - `WorkflowSettings::resolve_for_run`). -- References to per-namespace resolved type names that will be renamed to - `*Namespace`: ~57 across the workspace. + `fabro-cli`, `fabro-server`, `fabro-workflow`, `fabro-install`. Re-run + `grep -rn "resolve_server_from_file" lib/` before Unit 4 to confirm the + full set. +- `resolve_cli_from_file` outside `fabro-config`: `user_config.rs`, + `attach.rs` (deleted in Unit 5). +- `materialize_settings_layer` outside tests: `run_manifest.rs` only. +- Per-namespace type name references to rename: ~57. ### Institutional Learnings -- No prior `docs/solutions/` entries cover this area; the refactor starts - from current code and the adjacent brainstorm. +- No prior `docs/solutions/` entries cover this area. ## Key Technical Decisions -- **Four types collapse to three.** An earlier sketch carried a - `LocalWorkflowSettings` type to serve `fabro settings --local`. Deleting - the `--local` flag eliminates the only caller and lets - `LocalWorkflowSettings` go away entirely. **Rationale:** the - diagnostic value of `--local` is narrow (first-run sanity checks, - debugging a server that can't start), mostly covered by reading - `~/.fabro/settings.toml` directly or by a future purpose-built - `fabro doctor`-style command. The simplification — one fewer type, no - mode enum, one merge path — dominates. -- **`UserSettings`, not `CliSettings`, for the CLI-process context type.** - Reflects both the *source* (`~/.fabro/settings.toml` is the user's file) - and the *scope* (personal/machine preferences). Also avoids the collision - with the now-renamed `cli.*` namespace type (`CliNamespace`) that lives - inside it. -- **`WorkflowSettings` includes `cli.*`.** The server does not read - `cli.*` for any decision (audited: zero reads in `fabro-server`, - `fabro-workflow`, `fabro-agent`). But `cli.*` is meaningfully *stored - per-run* so that `fabro run attach` can reproduce submit-time - `output.verbosity`. Treating `cli.*` inside `WorkflowSettings` as an - opaque snapshot of client state at submit time, not as a server input, - matches this usage and lets the CLI round-trip the info it needs. -- **`features.*` appears on all three context types.** Feature flags are - cross-cutting; both the CLI process and the server consult them, and a - run carries its own snapshot. Small cost for a uniform rule. -- **Delete `EffectiveSettingsMode` entirely, not just `RemoteServer`.** - After `LocalOnly` (Unit 1) and `RemoteServer` (Unit 2) are both removed, - the only remaining variant is `LocalDaemon`. A one-variant enum has no - value; the function loses its `mode` parameter and becomes a single - straight-line implementation. -- **`GET /api/v1/settings` collapses to one dense shape.** The current - `view=layer|resolved` toggle and `X-Fabro-Settings-View` header go away. - The endpoint returns a typed `ServerSettings` (schema properly described, - not `additionalProperties: true`). **Rationale:** the layer shape is an - internal representation, not a client contract; once internal code doesn't - pass layers around, the API shouldn't either. -- **`fabro settings` composes local + remote views.** Without `--local` - and with a narrower server response, the CLI constructs its display from - both sides: local `UserSettings::resolve()` (for `cli` + local `features`) - and the server's `ServerSettings` (for `server` + server's `features`). - This is more truthful than today's behavior, where the server synthesizes - a cross-namespace view that conflates its `cli.*` with the client's. -- **`EffectiveSettingsLayers` stays `pub`** (in `fabro-config`), since - `fabro-server/run_manifest.rs` is the one external caller that needs to - build one to pass to `WorkflowSettings::resolve_for_run`. Its - construction is simple enough that a builder isn't warranted. -- **Placement of context types.** New context types live in `fabro-types` - alongside existing namespace types (keeping the type definitions in one - crate and the resolution logic in `fabro-config`). `impl` blocks for - `::resolve*()` live in `fabro-config` via extension traits or inherent - impls in the resolution module — whichever is mechanically simplest - given orphan rules; this is an implementation-time call. +- **Layer and view are distinct roles.** `SettingsLayer` is sparse + transport/storage. Context types are dense, owner-scoped, resolved views + computed from a layer (or combination) at read time. Persisted artifacts + are layers; live reads are views. These don't overlap and don't convert + in place. + +- **`GET /api/v1/settings` serves `AppState` in memory.** The server + builds its `ServerSettings` at startup from the *effective runtime + layer* (post-`apply_runtime_settings`, which folds in CLI overrides + like `--storage-dir` and `--bind`) and stores it in `AppState`. + Hot-reload keeps the derived view in sync via + `state.replace_settings(...)`. The handler returns a clone of the + current value. No per-request disk read. No view toggle. No + redaction. No response header. + +- **No `WorkflowSettings` in this PR.** An earlier draft introduced a + `WorkflowSettings` context type as the dense resolved view for + server-side execution, but the refactor has no production caller that + needs it: `operations/create.rs` migrates to per-namespace resolvers + (legitimate stored-layer read), `server.rs:1337` is deleted wholesale + as part of Unit 7's view-toggle removal, and `settings_view.rs` + disappears entirely. Introducing `WorkflowSettings::resolve_for_run` + with no caller is speculative abstraction. If future server code + wants a dense multi-namespace view, it can add the type then with a + real consumer. `PreparedManifest.settings` stays `SettingsLayer` + (unchanged by this refactor); server execution code reads specific + namespaces via per-namespace resolvers as it does today. + +- **Attach honors live CLI settings.** `fabro run attach` calls + `UserSettings::resolve()` on the attaching process's config. + Submit-time `cli.*` on a stored run is inert — no code reads it back. + +- **Two constructors per context type.** + `ServerSettings::from_layer(&SettingsLayer)` is the primitive; + `ServerSettings::resolve()` loads the default + `~/.fabro/settings.toml` and delegates. `UserSettings` has the same + pair. No `resolve_from(path)`: `--config` handling is an existing + `serve.rs` concern that produces the on-disk settings layer before + `apply_runtime_settings` runs; the new `ServerSettings::from_layer` + is invoked on the resulting *effective runtime layer*, not on a + fresh disk read. + +- **Delete `EffectiveSettingsMode`.** Production always picks + `LocalDaemon`; the enum is ceremony. `materialize_settings_layer` + becomes a single straight-line function. + +- **Delete redaction.** Settings contain no secrets. `settings_view.rs`, + `redact_for_api`, `redact_resolved_value`, `SettingsApiView`, + `SettingsQuery`, `X-Fabro-Settings-View` — all deleted. Both settings + endpoints serialize directly. If a future field should not be exposed, + the fix is to type it as `InterpString`, not to reintroduce redaction. + +- **`fabro settings` composes local + server.** The CLI renders + `UserSettings::resolve()` (local) plus the server's `ServerSettings` + (fetched via the endpoint). Two sections. + +- **Typed OpenAPI schema via `Deserialize` + `with_replacement`.** + Aligned with CLAUDE.md's API type ownership doctrine. The `ServerSettings` + OpenAPI schema describes the internal Rust type directly; progenitor + reuses it via `with_replacement`. Reachable namespace types gain + `Deserialize` derives; types with custom `Serialize` get matching custom + `Deserialize` (notably `InterpString`, which must preserve unresolved + templates). + +- **`features.*` on both context types — the one carve-out to + owner-first.** Cross-cutting by design; server and CLI both gate + behavior on feature flags, and server execution code consumes them + via its per-namespace `resolve_features_from_file` reads on stored + layers. + +- **Context types live in `fabro-config`.** Inherent `impl` blocks must + live with the type per Rust's orphan rules. `fabro-types` keeps only + per-namespace shape types. + +- **Per-namespace resolver visibility, decided once:** all six + `resolve_*_from_file` helpers stay `pub`, because each has at least + one cross-crate consumer: + - `resolve_server_from_file` — `runner.rs:508` reads `server.*` off + stored `record.settings`. + - `resolve_run_from_file` — `runner.rs:507`, `run_manifest.rs:369`, + `operations/create.rs` read `run.*` off stored layers. + - `resolve_project_from_file`, `resolve_workflow_from_file` — + `operations/create.rs` reads their `.metadata` for label + aggregation. + - `resolve_cli_from_file` — `fabro-cli/tests/it/cmd/create.rs:364` + integration test asserts the persisted `cli.*` wire shape. + - `resolve_features_from_file` — `fabro-server/src/server.rs:1414` + reads `features.session_sandboxes`; the + `fabro-config/tests/resolve_features.rs` integration test also + depends on it. +- **Other `fabro-config` visibility:** + - `pub(crate)`: `materialize_settings_layer` — internal helper; the + merge step stays inside `run_manifest.rs`'s call site, consumed only + via the public `SettingsLayer` output. + - `pub`: `user::load_settings_config` — external caller in + `fabro-server/src/serve.rs` loads the on-disk config layer before + `apply_runtime_settings`. + - `pub(crate)`: `EffectiveSettingsLayers` — no external consumer + remains after `WorkflowSettings` is dropped (`run_manifest.rs` + builds layers internally for its own `materialize_settings_layer` + call). ## Open Questions ### Resolved During Planning -- *Should `cli.*` be on `WorkflowSettings`?* Yes — see "Key Technical - Decisions." Snapshot semantics for attach, not a server input. -- *Should `features.*` duplicate across all three types?* Yes. Uniform - rule; low cost; all three contexts consult feature flags. -- *Should we keep a `fabro settings --local` equivalent for diagnostics?* - No. Deleted outright; re-introduce as a targeted `fabro doctor`-style - command later if needed. -- *Does the OpenAPI response narrow?* Yes. `GET /api/v1/settings` returns - only `server` + `features` namespaces. The CLI composes the rest locally. +- *Introduce a `WorkflowSettings` context type?* No. The refactor has + no production caller that needs a dense multi-namespace view — + `operations/create.rs` migrates to per-namespace resolvers, the + view-toggle branches are deleted. Adding the type speculatively + violates the no-speculative-cleanup directive. +- *Context types' crate?* `fabro-config`. +- *`GET /api/v1/settings` re-read from disk per request?* No. Serves + `AppState.server_settings`. +- *`PreparedManifest.settings` retype?* No. Stays `SettingsLayer`. +- *Stored-layer readers migrate to context types?* No. They stay on + per-namespace resolvers. +- *Redaction for the new endpoints?* None. Deleted entirely. ### Deferred to Implementation -- **Exact placement of `impl ServerSettings { fn resolve() ... }` etc.** - Likely in `fabro-config` to keep resolution logic colocated with the - merge code, but orphan rules may push them back into `fabro-types`. - Mechanical call at implementation time. -- **`fabro settings` output layout.** The command currently dumps a flat - JSON/YAML tree with all six namespaces. Post-refactor it renders two - sections (user / server). The exact shape is a small UX call; snapshot - tests drive the answer during implementation. -- **Whether `resolve_run_from_file` and siblings survive.** Callers like - `fabro-cli/src/commands/run/attach.rs` and - `fabro-cli/src/commands/run/runner.rs` currently pluck individual - namespaces out of a stored `SettingsLayer`. They may migrate to - `WorkflowSettings::from_stored_layer(layer, server)` or keep using - per-namespace resolvers (now `pub(crate)`-visible through an adapter). - Resolved during Unit 6 based on which reads cleanly; either is fine. -- **Whether `EffectiveSettingsLayers::new` should gain a fluent builder.** - Single caller today; keep the existing positional constructor unless - Unit 6 surfaces a reason. +- `fabro settings` output layout (two-section render — labels, ordering, + field suppression). Snapshot tests drive. ## 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.* +> *Directional guidance, not implementation specification.* -**Type inventory after refactor:** +**Type inventory:** ``` -// Per-namespace resolved types (renamed) -ServerNamespace // was ServerSettings -CliNamespace // was CliSettings -ProjectNamespace // was ProjectSettings -WorkflowNamespace // was WorkflowSettings -RunNamespace // was RunSettings -FeaturesNamespace // was FeaturesSettings +// Per-namespace dense types (renamed from today's *Settings) +ServerNamespace, CliNamespace, ProjectNamespace, +WorkflowNamespace, RunNamespace, FeaturesNamespace -// Context types (new) -ServerSettings { server: ServerNamespace, features: FeaturesNamespace } -UserSettings { cli: CliNamespace, features: FeaturesNamespace } -WorkflowSettings { server, project, workflow, run, cli, features } - // same field set as today's `Settings` god type +// Context types (new; all in fabro-config) +ServerSettings { server: ServerNamespace, features: FeaturesNamespace } +UserSettings { cli: CliNamespace, features: FeaturesNamespace } ``` -**Public entry points (sketch — directional):** +**Public constructors:** ``` impl ServerSettings { - fn resolve() -> Result; // reads ~/.fabro/settings.toml - fn resolve_from(path: &Path) -> Result; // honors --config override + fn from_layer(&SettingsLayer) -> Result; + fn resolve() -> Result; } impl UserSettings { - fn resolve() -> Result; - fn resolve_from(path: &Path) -> Result; -} - -impl WorkflowSettings { - fn resolve_for_run( - layers: EffectiveSettingsLayers, - server: &ServerSettings, - ) -> Result; + fn from_layer(&SettingsLayer) -> Result; + fn resolve() -> Result; } ``` -**Call-site topology, before and after:** +**Role separation:** ``` -Before After -────── ───── -user::load_settings_config(path) ─► ServerSettings::resolve_from(path) - → resolve_server_from_file(&layer) +SettingsLayer — sparse transport/storage. Unchanged. +ServerSettings — dense view. AppState holds one; API serves it. +UserSettings — dense view. CLI process builds one. +``` -user::load_settings_config(None) ─► UserSettings::resolve() - → resolve_cli_from_file(&layer) +**Call-site topology:** -EffectiveSettingsLayers + layers ─► WorkflowSettings::resolve_for_run( - materialize_settings_layer( layers, &server_settings) - layers, Some(server), LocalDaemon) +``` +Before After +────── ───── +resolve_server_from_file(&layer) ─► ServerSettings::from_layer(&layer) + (for current-config reads) (stored-layer reads keep + resolve_*_from_file) + +GET /api/v1/settings: load + resolve ─► GET /api/v1/settings: clone AppState.server_settings + + redact + serialize per request + +resolve_cli_from_file(&layer) ─► UserSettings::resolve() or ::from_layer +attach reads stored cli.* ─► attach calls UserSettings::resolve() + +fabro_config::resolve(&layer) ─► per-namespace resolvers at each call site + (god-type god fn) in create.rs (project/workflow/run metadata reads) + +materialize_settings_layer(...) ─► materialize_settings_layer(...) + (stays at run_manifest.rs merge site; + loses the `mode` parameter; output + still stored in PreparedManifest.settings + as SettingsLayer) + +Settings god type ─► deleted; no named replacement + +runner.rs / operations/create.rs: ─► unchanged (pub per-namespace resolvers) + per-namespace stored-layer reads ``` **Unit dependency graph:** @@ -348,13 +372,13 @@ EffectiveSettingsLayers + layers ─► WorkflowSettings::resolve_for_run( ```mermaid flowchart TB U1[Unit 1: Delete --local] - U2[Unit 2: Delete RemoteServer + mode enum] - U3[Unit 3: Rename per-namespace types] - U4[Unit 4: ServerSettings::resolve] - U5[Unit 5: UserSettings::resolve] - U6[Unit 6: WorkflowSettings::resolve_for_run] - U7[Unit 7: Narrow GET /api/v1/settings] - U8[Unit 8: Privatize merge internals] + U2[Unit 2: Delete mode enum] + U3[Unit 3: Rename to *Namespace] + U4[Unit 4: ServerSettings + AppState] + U5[Unit 5: UserSettings + live attach] + U6[Unit 6: Delete Settings god type] + U7[Unit 7: New /api/v1/settings shape] + U8[Unit 8: Privatize internals] U1 --> U2 U2 --> U3 @@ -362,23 +386,17 @@ flowchart TB U3 --> U5 U3 --> U6 U4 --> U7 + U6 --> U7 U4 --> U8 U5 --> U8 U6 --> U8 - U6 --> U7 ``` -`U4`, `U5`, and `U6` can progress in parallel once `U3` lands. `U7` -depends on `U4` and `U6` (the server handler for `GET /api/v1/settings` -needs `ServerSettings::resolve`, and the CLI's `fabro settings` command -consumes both the remote `ServerSettings` and local `UserSettings`). - ## Implementation Units - [ ] **Unit 1: Delete `fabro settings --local` and the `LocalOnly` path** -**Goal:** Remove `--local` flag, its helpers, and the `LocalOnly` variant -of `EffectiveSettingsMode`. `fabro settings` always talks to the server. +**Goal:** Remove `--local`, its helpers, and the `LocalOnly` variant. **Requirements:** R4. @@ -390,618 +408,585 @@ of `EffectiveSettingsMode`. `fabro settings` always talks to the server. - Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` — delete `local_settings_value`, `workflow_and_project_layers`, `config_layers`, `strip_nulls`, `resolve_local_settings_value`, `render_resolve_errors`, - and the `args.local` / `args.workflow` branches in `rendered_config`. -- Modify: `lib/crates/fabro-cli/src/main.rs` — remove the `args.local` - snapshot-test assertions (lines ~1072, ~1097). + and the `args.local` / `args.workflow` branches. +- Modify: `lib/crates/fabro-cli/src/main.rs` — remove `args.local` + snapshot-test assertions. - Modify: `lib/crates/fabro-config/src/effective_settings.rs` — remove `LocalOnly` variant and its match arm. -- Modify: `lib/crates/fabro-config/tests/resolve_root.rs` — drop the - `LocalOnly` test case (or migrate to `LocalDaemon` if still useful). -- Modify: `lib/crates/fabro-config/src/effective_settings.rs` (tests - module) — drop the `LocalOnly` test at line ~218/~307. -- Modify: CLI snapshot fixtures for `fabro settings --local` if any exist - under `lib/crates/fabro-cli/tests/`. -- Test: `lib/crates/fabro-cli/tests/it/cmd/config.rs` (or wherever the - settings-command tests live) — replace `--local` cases with - through-server cases. +- Modify: `lib/crates/fabro-config/tests/resolve_root.rs` and the + `effective_settings.rs` tests module — drop `LocalOnly` cases. +- Delete: `lib/crates/fabro-cli/tests/it/cmd/config.rs` tests that depend + on `--local` (e.g., `settings_local_merges_cli_and_project_defaults`, + `settings_local_workflow_name_applies_run_overlay_and_deep_merges`). + They assert filesystem-walked behavior that's being removed entirely. **Approach:** -- `--workflow WORKFLOW` was only meaningful together with `--local` (the - current code bails if it's passed alone). Both flags go. -- Keep the `fabro settings` command working via the existing - `ctx.server().retrieve_resolved_server_settings()` path. That path - changes shape in Unit 7, not here. -- `LocalOnly` removal is purely dead-code deletion once the CLI stops - calling it; safe to do in the same unit. - -**Patterns to follow:** -- Existing CLI argument deletion pattern (check recent commits for - precedent on removed flags). +- `--workflow WORKFLOW` was only meaningful with `--local`. Both go. +- `fabro settings` continues to work via the server path (reshaped in + Unit 7). +- `LocalOnly` removal is dead-code deletion once the CLI stops calling it. **Test scenarios:** -- *Happy path:* `fabro settings` (no args) with a running server returns - the server's resolved settings. Unchanged from pre-refactor behavior - modulo Unit 7's narrowing. -- *Error path:* `fabro settings --local` no longer parses; - the CLI's usage error mentions the flag is gone (or the help output - omits it, which is enough). -- *Error path:* `fabro settings WORKFLOW_ARG` no longer parses — the - positional was only meaningful with `--local`. +- *Contract:* `fabro settings` with a running server returns the + server's settings (shape reshaped in Unit 7). +- *Parser-level:* `fabro settings --local` fails to parse. `fabro settings + WORKFLOW_ARG` fails to parse. **Verification:** -- `cargo build --workspace` succeeds. -- `cargo nextest run -p fabro-cli` passes; snapshot tests for - `fabro settings` no longer reference `--local`. -- `grep -rn "LocalOnly\|args\.local\|SettingsArgs.*local" lib/` returns - only deliberate references (e.g., doc comments), not code paths. +- `cargo build --workspace` and `cargo nextest run -p fabro-cli` pass. +- `grep -rn "LocalOnly\|args\.local" lib/` returns only deliberate doc + references. --- -- [ ] **Unit 2: Delete `EffectiveSettingsMode::RemoteServer` and collapse the enum** +- [ ] **Unit 2: Delete `EffectiveSettingsMode` entirely** -**Goal:** Migrate test-only `RemoteServer` calls to `LocalDaemon`, then -delete the enum entirely. `materialize_settings_layer` loses its `mode` -parameter. +**Goal:** Kill the mode enum. `materialize_settings_layer` becomes a +single straight-line function. **Requirements:** R3. -**Dependencies:** Unit 1 (removes `LocalOnly`; after both are gone, only -`LocalDaemon` remains). +**Dependencies:** Unit 1. **Files:** - Modify: `lib/crates/fabro-config/src/effective_settings.rs` — delete - `EffectiveSettingsMode` enum, remove `mode` parameter from - `materialize_settings_layer`, flatten the match into a single code path - (strip owner domains, merge, apply `apply_local_daemon_overrides`). + `EffectiveSettingsMode`; remove `mode` parameter from + `materialize_settings_layer`; flatten to a single path (strip owner + domains, merge, apply server authority). Rename + `apply_local_daemon_overrides` → `enforce_server_authority`. Delete + `apply_server_defaults` (no surviving caller). - Modify: `lib/crates/fabro-config/src/lib.rs` — update internal - `load_and_resolve` to match new signature. + `load_and_resolve` to match. - Modify: `lib/crates/fabro-server/src/run_manifest.rs` — call `materialize_settings_layer(layers, Some(server_settings))` without a - mode; remove the `local_daemon_mode` plumbing if it was solely driving - the enum choice. If `local_daemon_mode` is used elsewhere in AppState - for non-settings purposes, leave that alone. + mode. - Modify: `lib/crates/fabro-server/src/server.rs` — remove the - `local_daemon_mode` field from `AppState` (~line 581) and - `AppStateConfig` (~line 598); update the three handler call sites - that pass it into `run_manifest::prepare_manifest_with_mode` - (`create_run` ~line 4104, `run_preflight` ~line 4212, - `render_graph_from_manifest` ~line 4242); update helper function - signatures (~lines 2515, 2541, 2641, 2708); remove the test helper - at ~line 2575. Can be phrased as "remove the `local_daemon_mode` - field and all its threading." -- Modify: `lib/crates/fabro-config/src/effective_settings.rs` (tests - module) — replace `RemoteServer` test at line ~378 with a - `LocalDaemon`-style assertion, or delete if redundant. + `local_daemon_mode` field from `AppState` and `AppStateConfig`; update + the three handler call sites that thread it (`create_run` ~line 4104, + `run_preflight` ~line 4212, `render_graph_from_manifest` ~line 4242); + update helper signatures (~lines 2515, 2541, 2641, 2708); remove the + test helper at ~line 2575. +- Modify: `effective_settings.rs` tests module — delete the + `RemoteServer` test case (`cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode`). + Verify its unique assertions are covered by the surviving `LocalDaemon` + test at ~line 437; if any coverage is unique, fold that assertion into + the surviving test. **Approach:** -- Verify `local_daemon_mode` in `fabro-server` is only used for settings - mode selection before deleting it. Preliminary inspection says yes - (`serve.rs:523` sets it true, tests set it false, `run_manifest.rs` - consumes it only to pick between `RemoteServer` and `LocalDaemon`). -- `apply_server_defaults` (the RemoteServer-specific code path) should be - deletable along with the enum — no surviving caller. -- Keep `apply_local_daemon_overrides` (now the only override strategy); - consider renaming it to `apply_server_overrides` since "LocalDaemon" is - about to stop being a named concept. - -**Patterns to follow:** -- `strip_owner_domains` and `apply_local_daemon_overrides` stay as - private helpers inside `effective_settings`. +- `apply_server_defaults` (the `RemoteServer` code path) has no surviving + caller; delete along with the enum. +- Keep `strip_owner_domains` and `enforce_server_authority` as private + helpers. **Test scenarios:** -- *Happy path:* `materialize_settings_layer` with representative layers - produces the same output as the pre-refactor `LocalDaemon` invocation - (server owns storage/scheduler/artifacts/web; project/workflow - `[cli]/[server]` stripped). -- *Edge case:* When `server_settings` is `Some(empty_layer)`, the output - preserves client values and doesn't panic. +- *Contract:* `materialize_settings_layer` with representative layers + produces the same output as the pre-refactor `LocalDaemon` invocation. +- *Edge case:* `Some(empty_layer)` for server settings preserves client + values and doesn't panic. **Verification:** -- `cargo build --workspace` succeeds. -- `cargo nextest run -p fabro-config` passes. -- `grep -rn "EffectiveSettingsMode\|RemoteServer\|LocalDaemon" lib/` - returns no hits. +- `cargo build --workspace` and `cargo nextest run -p fabro-config` pass. +- `grep -rn "EffectiveSettingsMode\|RemoteServer\|LocalDaemon\|local_daemon_mode" lib/` + returns zero hits. --- -- [ ] **Unit 3: Rename per-namespace resolved types to `*Namespace` suffix** +- [ ] **Unit 3: Rename per-namespace resolved types to `*Namespace`** -**Goal:** Free the short names (`ServerSettings`, `CliSettings`, etc.) for -the new context types by renaming today's per-namespace types. +**Goal:** Free the short names for context types. **Requirements:** R6. -**Dependencies:** Units 1 and 2 (so the file churn touches stable code). +**Dependencies:** Units 1, 2. **Files:** -- Modify: `lib/crates/fabro-types/src/settings/mod.rs` — rename the - re-exports. -- Modify: `lib/crates/fabro-types/src/settings/server.rs` — rename - `ServerSettings` to `ServerNamespace`; adjust references. -- Modify: `lib/crates/fabro-types/src/settings/cli.rs` — rename - `CliSettings` to `CliNamespace`; propagate. -- Modify: `lib/crates/fabro-types/src/settings/project.rs`, - `workflow.rs`, `run.rs`, `features.rs` — same pattern. +- Modify: `lib/crates/fabro-types/src/settings/{mod,server,cli,project,workflow,run,features}.rs` + — rename each per-namespace type (`ServerSettings` → `ServerNamespace`, + etc.). - Modify: `lib/crates/fabro-types/src/settings/resolved.rs` — update the - `Settings` god-type field types. + god-type field types (the god type itself goes away in Unit 6). - Modify: `lib/crates/fabro-config/src/resolve/mod.rs` and siblings — - update return types of `resolve_server_from_file` (now `ServerNamespace`) - and similar; function names unchanged in this unit. -- Modify: all ~57 other references across the workspace (mechanical). + update return types. +- Modify: ~57 other references across the workspace (mechanical). -**Approach:** -- Type-only rename. Function names, field names, and serde wire format - are unchanged. -- Use `cargo check --workspace` after each rename to catch missed sites. -- `replace_all` is safe for most grep hits; verify nothing outside the - settings domain shares these names (prior grep confirmed no collisions). +**Approach:** Type-only rename. Function names, field names, and serde +wire format unchanged. Use `cargo check --workspace` between file batches. -**Execution note:** Mechanical rename, suitable for `Execution target: -external-delegate` if desired. +**Execution note:** Mechanical; suitable for `Execution target: +external-delegate`. -**Patterns to follow:** -- Recent refactors in this repo tend to land renames in one commit per - type-family when the blast radius is contained. Single unit per this - plan is fine. - -**Test scenarios:** -- *Happy path:* All existing tests continue to pass — this unit changes - no behavior. The compiler is the primary witness. +**Test scenarios:** All existing tests continue to pass. Compiler is the +primary witness. **Verification:** - `cargo build --workspace` and `cargo nextest run --workspace` pass. - `grep -rn "fabro_types::settings::\(ServerSettings\|CliSettings\|ProjectSettings\|WorkflowSettings\|RunSettings\|FeaturesSettings\)\b" lib/` - returns no hits (all references now use `*Namespace`). -- `cargo +nightly-2026-04-14 clippy --workspace --all-targets - -- -D warnings` clean. + returns zero hits. +- Clippy clean. --- -- [ ] **Unit 4: Introduce `ServerSettings` context type and `::resolve*()` constructors** +- [ ] **Unit 4: Introduce `ServerSettings`; `AppState` holds one** -**Goal:** Add the new `ServerSettings { server, features }` type with -constructors, and migrate all existing `resolve_server_from_file` callers. +**Goal:** Add `ServerSettings` with `from_layer` + `resolve`. Build one at +server startup and store in `AppState`. Migrate current-config callers. **Requirements:** R1, R2. **Dependencies:** Unit 3. **Files:** -- Create / Modify: `lib/crates/fabro-types/src/settings/context.rs` - (new module) — define `ServerSettings` struct. -- Modify: `lib/crates/fabro-types/src/settings/mod.rs` — re-export - `ServerSettings` at the crate root. -- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` or a new - `context.rs` — add `impl ServerSettings { fn resolve(); fn resolve_from(path); }`. -- Modify: every caller of `resolve_server_from_file` (~20 sites across - `fabro-cli`, `fabro-server`, `fabro-install`). -- Modify: `lib/crates/fabro-workflow/src/operations/start.rs` — the - `resolve_server_from_file` call at ~line 384 migrates along with the - other sites. (Earlier call-site inventory missed `fabro-workflow`; - re-run `grep -rn "resolve_server_from_file" lib/` before Unit 4 to - confirm the full set, including test harnesses under - `fabro-server/tests/it/` and `fabro-cli/tests/it/support/`.) -- Modify: `lib/crates/fabro-config/src/user.rs` — make - `load_settings_config` `pub(crate)` (it becomes an implementation detail - of `ServerSettings::resolve_from`). -- Modify: `bin/dev/check-boundary.sh` — add grep patterns for the new - `ServerSettings::resolve` / `ServerSettings::resolve_from` symbols so - the regression guard covers the migration window, not just the - pre-rename state. (Moved here from Unit 8 so the script never goes - blind to the new symbol between Unit 4 and Unit 8.) -- Test: `lib/crates/fabro-config/tests/resolve_server.rs` — add tests - for `ServerSettings::resolve_from(path)` that parallel existing - per-function tests. +- Create: `lib/crates/fabro-config/src/context.rs` — `ServerSettings { + server: ServerNamespace, features: FeaturesNamespace }`. Inherent + `from_layer(&SettingsLayer) -> Result` and `resolve() -> + Result`. +- Modify: `lib/crates/fabro-config/src/lib.rs` — re-export `ServerSettings`. +- Modify: `lib/crates/fabro-server/src/serve.rs` (~lines 478-482) — + startup already computes + `effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir)` + (the post-runtime-override layer that folds in `--storage-dir`, + `--bind`, etc.). Add + `let server_settings = ServerSettings::from_layer(&effective_settings)?;` + alongside the existing `resolved_server_settings = + resolve_server_settings(&effective_settings)?` line, and thread + `server_settings` into `AppState`. Derive from the effective runtime + layer, not from a fresh `~/.fabro/settings.toml` read; a fresh read + would drop the CLI overrides. +- Modify: `lib/crates/fabro-server/src/serve.rs` (~line 600, the + hot-reload path) — when `apply_runtime_settings(...)` is rerun and + `state_for_poll.replace_settings(effective)` is called, also refresh + `AppState.server_settings` from the new layer so the typed view + stays in sync with the `Arc>`. +- Modify: `lib/crates/fabro-server/src/server.rs` — add + `server_settings: Arc` (or equivalent interior + mutability for hot-reload refresh) to `AppState`. Extend + `replace_settings(...)` to also update the derived `ServerSettings` + so both are consistent after reload. +- Modify: every current-config caller of `resolve_server_from_file` (~20 + sites across `fabro-cli`, `fabro-server`, `fabro-workflow`, + `fabro-install`) — switch to `ServerSettings::from_layer(&layer)` where + a layer is already in hand, or `ServerSettings::resolve()` where + defaults apply. Stored-layer readers (`runner.rs:507-508`) stay on + `resolve_server_from_file`. +- Modify: `bin/dev/check-boundary.sh` — add grep patterns for + `ServerSettings::resolve` and `ServerSettings::from_layer` so the + regression guard covers the migration window. +- Test: `lib/crates/fabro-config/tests/resolve_server.rs` — add tests for + `ServerSettings::from_layer` and `ServerSettings::resolve`. **Approach:** -- `ServerSettings::resolve()` reads `Home::from_env().user_config()`. -- `ServerSettings::resolve_from(path)` honors an explicit override (the - `--config` flag case). -- Internally both call the same `user::load_settings_config` → - `resolve_server_from_file(&layer)` pipeline; the free function - `resolve_server_from_file` stays available as `pub(crate)` for - Unit 6 / Unit 8 to handle. -- Mechanical migration of ~20 call sites. Most are a one-line - substitution (`fabro_config::resolve_server_from_file(&layer)` → - `ServerSettings::resolve_from(path)` or similar). - -**Patterns to follow:** -- Existing inherent-impl pattern in `fabro-types` for dense types. -- `fabro_cli::local_server` continues to be the only module importing - `ServerSettings` in the CLI crate (with a narrow API surface); confirm - `bin/dev/check-boundary.sh` still passes. +- `from_layer` invokes the per-namespace resolver and wraps the result. + Single primitive. +- `resolve()` loads the default `~/.fabro/settings.toml` and delegates + to `from_layer`. Useful for tools (`fabro doctor`-style), + integration tests, and any process that wants "the defaults on this + machine." **Not** used by the server startup path: the server's + authoritative settings are the *effective runtime layer* produced by + `apply_runtime_settings`, which a fresh disk read doesn't see. +- Server startup: after `serve.rs` builds `effective_settings = + apply_runtime_settings(...)`, it calls + `ServerSettings::from_layer(&effective_settings)` and stores the + result in `AppState`. Hot-reload (`state.replace_settings(...)`) also + refreshes that derived view. +- `AppState.server_settings` is the server's canonical current-config + value. Handlers read it, not disk. It's always in sync with the + layer in `AppState`'s `RwLock`. **Test scenarios:** -- *Happy path:* `ServerSettings::resolve_from(valid_path)` returns the - same `ServerNamespace` + `FeaturesNamespace` values that - `resolve_server_from_file(&parsed_layer)` would have returned. -- *Happy path:* `ServerSettings::resolve()` with `$FABRO_HOME` set points - to a temp dir successfully loads that directory's `settings.toml`. -- *Error path:* `ServerSettings::resolve_from(nonexistent_path)` returns - the file-not-found error (not a panic, matches current behavior). -- *Error path:* `ServerSettings::resolve_from(path_with_invalid_toml)` - returns a parse error that preserves today's error formatting. -- *Integration:* the server's startup path (`serve.rs:353`) boots - successfully using `ServerSettings::resolve_from`. +- *Contract:* `from_layer(&layer)` returns the same data as the old + free function on the same layer. +- *Contract:* `resolve()` with `$FABRO_HOME` set to a temp dir loads + that directory's `settings.toml`. +- *Integration:* Server startup derives `AppState.server_settings` + from the effective runtime layer (post-`apply_runtime_settings`). + A startup with `--storage-dir /tmp/foo` produces + `AppState.server_settings.server.storage` reflecting `/tmp/foo`, + confirming CLI overrides flow through to the typed view. +- *Integration:* Hot-reload (triggered via the existing + `state.replace_settings(effective)` pathway) refreshes + `AppState.server_settings` so the typed view reflects the updated + layer. **Verification:** - `cargo build --workspace` and `cargo nextest run --workspace` pass. - `grep -rn "resolve_server_from_file" lib/` returns only - `fabro-config/src/` internal references. -- `bin/dev/check-boundary.sh` still passes. -- `bin/dev/check-boundary.sh` catches a deliberate unsanctioned - `ServerSettings::resolve*` import outside the allowlist (confirm by - temporarily introducing one in a throwaway branch before merging). + `fabro-config/src/` and stored-layer reader call sites. +- `bin/dev/check-boundary.sh` still passes; verify on a throwaway branch + that it catches a deliberate unsanctioned `ServerSettings::resolve*` + import. --- -- [ ] **Unit 5: Introduce `UserSettings` context type and `::resolve*()` constructors** +- [ ] **Unit 5: Introduce `UserSettings`; attach uses live config** -**Goal:** Add `UserSettings { cli, features }` with constructors; migrate -`resolve_cli_from_file` callers outside the `--local` path (already -deleted in Unit 1). +**Goal:** Add `UserSettings` with `from_layer` + `resolve`. +`fabro run attach` reads the attaching process's live `UserSettings`. **Requirements:** R1, R2. **Dependencies:** Unit 3. **Files:** -- Modify: `lib/crates/fabro-types/src/settings/context.rs` — add - `UserSettings` next to `ServerSettings`. -- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` (or the - context module) — add `impl UserSettings { fn resolve(); - fn resolve_from(path); }`. -- Modify: `lib/crates/fabro-cli/src/user_config.rs` — replace the - `resolve_cli_from_file` usage with `UserSettings::resolve_from`. -- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs` — the - call at line ~90 reads `cli.output.verbosity` from a *stored* run - layer (not the user's live config). Decide in Unit 6 whether this - migrates to a `WorkflowSettings`-shaped access or keeps a - `pub(crate)` per-namespace resolver. Leave this call site alone in - Unit 5. +- Modify: `lib/crates/fabro-config/src/context.rs` — add `UserSettings { + cli: CliNamespace, features: FeaturesNamespace }`. Inherent + `from_layer(&SettingsLayer)` and `resolve()`. +- Modify: `lib/crates/fabro-config/src/lib.rs` — re-export `UserSettings`. +- Modify: `lib/crates/fabro-cli/src/user_config.rs` — replace + `resolve_cli_from_file` usage with `UserSettings::from_layer` or + `UserSettings::resolve()`. +- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs` — delete the + stored-layer `resolve_cli_from_file(&record.settings)` read at ~line 90. + Attach reads the attaching process's `UserSettings` (already threaded + through the command context) and honors its verbosity. +- Delete: any test that asserted submit-time verbosity preservation on + attach. Add a test that attach honors the attaching CLI's live + verbosity. - Test: `lib/crates/fabro-config/tests/resolve_cli.rs` — add - `UserSettings::resolve_from` cases. + `UserSettings::from_layer` and `UserSettings::resolve` cases. **Approach:** -- `UserSettings::resolve()` is the counterpart to - `ServerSettings::resolve()` for CLI-owned namespaces. Same loader, - different resolver. -- Today's `user_config.rs` wraps `resolve_cli_from_file` with error - formatting; shape that error formatting into `UserSettings::resolve_from` - or wrap at the call site. - -**Patterns to follow:** -- Same as Unit 4. +- Mirror `ServerSettings`'s shape. +- Attach-verbosity behavior changes: attach honors live settings. Stored + `cli.*` on a run is inert; wire format unchanged. **Test scenarios:** -- *Happy path:* `UserSettings::resolve_from(valid_path)` returns - `cli` + `features` namespaces, matching - `resolve_cli_from_file(&layer)` output. -- *Happy path:* `UserSettings::resolve()` picks up - `$FABRO_HOME/settings.toml`. +- *Contract:* `from_layer` and `resolve` return the expected namespaces. - *Edge case:* Missing `~/.fabro/settings.toml` returns defaults without - erroring (current behavior). -- *Error path:* Invalid TOML returns a parse error with the same shape as - today. + erroring. +- *Behavior:* `fabro run attach --verbose` against a non-verbose submitted + run prints verbose output; reversed case prints non-verbose output. **Verification:** - `cargo build --workspace` and `cargo nextest run --workspace` pass. -- `grep -rn "resolve_cli_from_file" lib/` returns only internal - `fabro-config/src/` references and (temporarily) the attach.rs site - to be addressed in Unit 6. +- `grep -rn "resolve_cli_from_file" lib/` returns only + `fabro-config/src/` internal references **plus** + `fabro-cli/tests/it/cmd/create.rs:364` (the integration test that + asserts the persisted `cli.*` wire shape — per the KTD visibility + policy, this test intentionally keeps `resolve_cli_from_file` + public). The `--local` call site at `commands/config/mod.rs:121` + was deleted in Unit 1; `user_config.rs` migrates to + `UserSettings::from_layer` in this unit; the `attach.rs` read is + deleted in this unit. --- -- [ ] **Unit 6: Introduce `WorkflowSettings` context type and `::resolve_for_run()`** +- [ ] **Unit 6: Delete the `Settings` god type and its ecosystem** -**Goal:** Add `WorkflowSettings` with all six namespaces, replace today's -`Settings` god type, and migrate the one `materialize_settings_layer` -call site in `fabro-server`. +**Goal:** Remove `fabro_types::settings::Settings`, the +god-type-returning `fabro_config::resolve` function, the +`fabro_config::load_and_resolve` helper, and all their dependent code +— tests and production — without introducing a named replacement +type. -**Requirements:** R1, R2, R7. +**Requirements:** R7. -**Dependencies:** Unit 3, Unit 4 (needs `ServerSettings` as the second -parameter to `resolve_for_run`). +**Dependencies:** Units 3 (renames) and 4 (context types exist for +migrated callers). **Files:** -- Modify: `lib/crates/fabro-types/src/settings/context.rs` — add - `WorkflowSettings { server, project, workflow, run, cli, features }`. -- Modify: `lib/crates/fabro-types/src/settings/resolved.rs` — delete the - `Settings` god type (now shadowed by `WorkflowSettings`). + +*Delete the type and wrapper functions:* +- Delete: `lib/crates/fabro-types/src/settings/resolved.rs` (the + `Settings` struct itself). - Modify: `lib/crates/fabro-types/src/settings/mod.rs` — remove the `Settings` re-export. -- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` (or context - module) — add `impl WorkflowSettings { fn resolve_for_run( - layers: EffectiveSettingsLayers, server: &ServerSettings) -> Result }`. -- Modify: `lib/crates/fabro-server/src/run_manifest.rs` — replace the - `materialize_settings_layer` call at line ~88 with - `WorkflowSettings::resolve_for_run(layers, &server_settings)`; - retype `PreparedManifest.settings` from `SettingsLayer` to - `WorkflowSettings`. -- Modify: `lib/crates/fabro-server/src/settings_view.rs:66` — update - `redact_resolved_value(&Settings)` to take `&WorkflowSettings`. -- Modify: downstream server code that reads `prepared.settings` — its - type narrowed from `SettingsLayer` to `WorkflowSettings`, which should - be a tightening rather than a loss (dense resolved values instead of - sparse layer). -- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs` and - `lib/crates/fabro-cli/src/commands/run/runner.rs` — migrate the - per-namespace resolver calls (`resolve_run_from_file`, - `resolve_server_from_file`, `resolve_cli_from_file` applied to a - stored `SettingsLayer`) to a single `WorkflowSettings::from_stored_layer` - constructor *or* keep the per-namespace access via a `pub(crate)` - adapter. Choose based on which reads cleanly at the call sites. -- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` — imports - and uses the `Settings` god type (~line 19 import; ~line 289 read via - `resolve_settings_tree` / `combined_labels`). Retype alongside the - god-type removal. -- Test: add tests in `lib/crates/fabro-config/tests/` for - `WorkflowSettings::resolve_for_run` covering owner-domain stripping - and server-authoritative overrides. +- Modify: `lib/crates/fabro-config/src/lib.rs` — delete the + `load_and_resolve` public helper (it returns `Settings`); delete the + `use fabro_types::settings::{Settings, SettingsLayer}` import (keep + the `SettingsLayer` import via its own `use`). +- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` — delete the + public `fn resolve(&SettingsLayer) -> Result` function + (the god-type-returning one); remove it from `pub use + resolve::{...}` in `lib.rs`. Per-namespace resolvers are unaffected. + +*Migrate the one production caller of `fabro_config::resolve`:* +- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` — uses + `Settings` at ~line 19 (import) and ~lines 289-298 + (`resolve_settings_tree` / `combined_labels`). The caller at ~line + 107 reads **both** `resolved_settings.server.storage.root` (to + compute `storage_root` for run persistence) and + `combined_labels(&resolved_settings)` (project/workflow/run + metadata). Replace `resolve_settings_tree` so it returns a small + struct (or 4-tuple) containing `ServerNamespace` + + `ProjectNamespace` + `WorkflowNamespace` + `RunNamespace`, built + from `fabro_config::resolve_server_from_file`, + `resolve_project_from_file`, `resolve_workflow_from_file`, and + `resolve_run_from_file` on the same `SettingsLayer`. Update the + call site at ~line 107 to read `.server.storage.root` off the + returned `ServerNamespace`, and `combined_labels` to read + `.metadata` off each of `ProjectNamespace` / `WorkflowNamespace` / + `RunNamespace`. All four per-namespace resolvers stay `pub` per + Unit 8. + +*Migrate or delete dependent tests:* +- Modify: `lib/crates/fabro-config/tests/resolve_root.rs` — tests + (including ~line 12 imports and ~line 43 call) currently exercise + `fabro_config::resolve`. Rewrite each assertion against the + surviving per-namespace resolvers: e.g., + `resolve_root.rs::resolves_root_settings_require_explicit_server_auth_methods` + becomes a test on `resolve_server_from_file(&SettingsLayer::default())`. + Tests that check multi-namespace behavior split into per-namespace + assertions. +- Modify: `lib/crates/fabro-config/tests/defaults.rs` — ~line 94 calls + `resolve(&SettingsLayer::default())`. Rewrite to target the specific + per-namespace resolver whose default the test is asserting (likely + `resolve_server_from_file`, from the `server.auth.methods` check + shown by grep). +- Modify: `lib/crates/fabro-cli/tests/it/cmd/config.rs` — ~line 160 + `resolved_server_settings_fixture` calls `fabro_config::resolve(...)` + to produce a Settings-shaped fixture for the settings command tests. + Replace with a fixture built via + `ServerSettings::from_layer(&server_settings_layer_fixture())` + (produces only `server` + `features`, matching the new + `GET /api/v1/settings` response shape). + +*Server.rs branch removed by Unit 7:* +- `lib/crates/fabro-server/src/server.rs:1337` (the + `fabro_config::resolve(&settings)` call inside the `?view=resolved` + branch of `retrieveServerSettings`) is already deleted by Unit 7's + handler simplification. No separate action needed here. + +*Settings_view deletion by Unit 7:* +- `lib/crates/fabro-server/src/settings_view.rs` (the + `redact_resolved_value(&Settings)` function plus test at ~line 257) + is deleted entirely by Unit 7. No separate action needed here. + +*Run_manifest.rs: mode parameter only:* +- Modify: `lib/crates/fabro-server/src/run_manifest.rs` — the existing + `materialize_settings_layer(layers, Some(server_settings), mode)` + call **stays**; this unit only removes the `mode` argument (Unit 2 + deleted the enum). `materialize_settings_layer` still produces the + merged `SettingsLayer` stored in `PreparedManifest.settings`. No + `WorkflowSettings` or other context type is constructed here. **Approach:** -- `WorkflowSettings` has the identical field set as today's `Settings`. - This unit is as much a rename + method-attachment as a new type. Once - renamed, the conceptual purpose is sharper: it's the per-run resolved - view, not a god bucket. -- `resolve_for_run` internally calls the (now private) - `materialize_settings_layer` and then the per-namespace resolvers to - produce each dense namespace. The implementation is straight-line. -- For `attach.rs` / `runner.rs`: a stored run's `SettingsLayer` is a - post-merge artifact (see `run_manifest.rs` where it's captured). A - `WorkflowSettings::from_stored_layer(layer, server)` that re-resolves - may be cleaner than three per-namespace calls. Confirm at - implementation time. - -**Execution note:** Start with a passing test for -`WorkflowSettings::resolve_for_run` that reproduces the current -`run_manifest.rs` output for a representative layer set; then migrate -the call site; then delete `Settings`. - -**Patterns to follow:** -- The existing resolve pipeline in `fabro-config/src/resolve/` — keep the - per-namespace resolvers as building blocks; `WorkflowSettings::resolve_for_run` - composes them. +- The god type has exactly one production consumer outside the + view-toggle machinery (`operations/create.rs`); migrate it. Every + other `fabro_config::resolve` call site is either a test (migrate or + delete) or inside code already being deleted by Unit 7. +- No replacement context type: stored-layer readers keep using + per-namespace resolvers per R1; current-config reads are covered by + `ServerSettings`/`UserSettings` (Units 4-5). +- `PreparedManifest.settings: SettingsLayer` is unchanged; the + run_manifest merge pipeline stays intact. **Test scenarios:** -- *Happy path:* `WorkflowSettings::resolve_for_run` with layers - containing `[project]`, `[workflow]`, `[run]` sections plus a - `ServerSettings` carrying non-default `server.storage` produces a - result where `server.storage` came from `ServerSettings` and run-level - fields came from the layers. -- *Happy path:* `cli.*` stanzas in the `project` or `workflow` layers - are stripped (owner-domain rule); only the user layer's `cli.*` - reaches the result. -- *Edge case:* Empty layers + non-empty `ServerSettings` produce a - result with `ServerSettings` values for server/features and default - values elsewhere. -- *Error path:* `resolve_for_run` returns the same resolution errors - (missing required fields, invalid enum values) that - `resolve_run_from_file` produces today on equivalent input. -- *Integration:* The full `run_manifest.rs` pipeline produces a - `PreparedManifest` whose `settings` field is a `WorkflowSettings` that - downstream validation (`validate_prepared_manifest`) accepts. +- *Contract:* `operations/create.rs` label aggregation produces the + same result as before — per-namespace metadata combined by the + replacement `resolve_settings_tree`. +- *Migration:* rewritten `resolve_root.rs` and `defaults.rs` tests + cover the same assertions at the per-namespace resolver level. +- *Migration:* the `fabro settings` command test in + `tests/it/cmd/config.rs` still passes with the `ServerSettings`-shaped + fixture. **Verification:** - `cargo build --workspace` and `cargo nextest run --workspace` pass. -- `grep -rn "fabro_types::settings::Settings\b" lib/` returns no hits. -- `grep -rn "materialize_settings_layer" lib/` returns only - `fabro-config/src/` internal references. +- `grep -rn "fabro_types::settings::Settings\b" lib/` returns zero + hits. +- `grep -rn "fabro_config::resolve\b" lib/` returns zero hits (the + god-type-returning function is gone; per-namespace + `fabro_config::resolve_*_from_file` and the new context-type + constructors remain). +- `grep -rn "load_and_resolve" lib/` returns zero hits. --- -- [ ] **Unit 7: Narrow `GET /api/v1/settings` to dense `ServerSettings`; compose `fabro settings` output** +- [ ] **Unit 7: Typed `GET /api/v1/settings` served from `AppState`; delete redaction** -**Goal:** Change the server's settings endpoint to return a single dense -shape matching the new `ServerSettings` context type. Update the CLI's -`fabro settings` to compose local `UserSettings` + remote `ServerSettings`. +**Goal:** The handler returns a typed `ServerSettings` from `AppState` in +memory. Delete the redaction machinery. Typed OpenAPI schema via +`Deserialize` + `with_replacement`. **Requirements:** R5. -**Dependencies:** Unit 4 (needs `ServerSettings`), Unit 6 (needs -`settings_view` migrated off `Settings`). +**Dependencies:** Units 4, 6. **Files:** -- Modify: `docs/api-reference/fabro-api.yaml` — at line ~1947 remove the - `view` query parameter and the `X-Fabro-Settings-View` header; at - line ~4897 replace the `ServerSettings` schema's - `additionalProperties: true` with a proper typed definition - (two fields: `server`, `features`) whose child schemas match the - new `ServerSettings` Rust type. -- Modify: `lib/crates/fabro-api/build.rs` — if `with_replacement(...)` - adapters are needed to wire the new `ServerSettings` Rust type into - the generated client, add them. -- Regenerate: `cargo build -p fabro-api` (build.rs runs progenitor). +- Modify: `docs/api-reference/fabro-api.yaml`: + - Remove the `view` query parameter and `X-Fabro-Settings-View` header + from the `retrieveServerSettings` operation. + - 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 + description to reflect that the endpoint returns the persisted + `SettingsLayer` as-is. + - Remove all `redact`, `redaction`, `secret subtrees` language across + the YAML. +- Modify: `lib/crates/fabro-api/build.rs` — add `with_replacement(...)` + entries mapping the OpenAPI `ServerSettings` schema (and nested types) + to the internal Rust types. +- Modify: `lib/crates/fabro-types/src/settings/server.rs`, `features.rs`, + and reachable child types — add `Deserialize` derives. For types with + custom `Serialize` (`serialize_socket_addr`, `InterpString`, etc.), + implement matching custom `Deserialize`. `InterpString::Deserialize` + must preserve unresolved `{{ env.NAME }}` templates. +- Add: `lib/crates/fabro-api/tests/` type-identity + JSON-parity test + (per CLAUDE.md's `with_replacement` requirement). +- Regenerate: `cargo build -p fabro-api` (progenitor runs in build.rs). - Regenerate: `cd lib/packages/fabro-api-client && bun run generate`. - Modify: `lib/crates/fabro-server/src/server.rs` — the - `retrieveServerSettings` handler (find by `operationId`) returns - `ServerSettings::resolve()`'s serialized form; remove the view-toggle - branching and the custom response header. -- Modify: `lib/crates/fabro-client/src/client.rs` — simplify - `retrieve_resolved_server_settings` (~lines 493-513) to drop the - `?view=resolved` query parameter and the `X-Fabro-Settings-View` - response-header check; update the error message and any CLI-side test - assertions that pin the old error text (e.g., - `lib/crates/fabro-cli/tests/it/cmd/config.rs`). -- Modify: `lib/crates/fabro-server/src/settings_view.rs` — reshape - redaction to work on a `ServerSettings` (dense) rather than a - `SettingsLayer` or `Settings` god type. `server.listen` still - redacts. -- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` — update - `rendered_config` to fetch the new `ServerSettings` shape from the - server and merge with a local `UserSettings::resolve()` for display. -- Modify: `apps/fabro-web/` or any other TypeScript consumer of the - generated `retrieveServerSettings` — if any consumer exists, update - to the new shape. (Initial inspection suggests the web app does not - call this endpoint; confirm at implementation time.) -- Run: `scripts/refresh-fabro-spa.sh` if any `apps/fabro-web/` code - changed. -- Test: `lib/crates/fabro-server/tests/it/openapi_conformance.rs` — - conformance test should verify the new single-shape endpoint matches - the spec. -- Test: snapshot tests for `fabro settings` output (under - `lib/crates/fabro-cli/tests/`) — regenerate and review with - `cargo insta pending-snapshots` → `cargo insta accept --snapshot ...`. + `retrieveServerSettings` handler returns a clone of + `AppState.server_settings`. No view toggle, no response header, no + redaction. The `/runs/:id/settings` handler serializes + `run_spec.settings` (the `SettingsLayer`) directly. +- Modify: `lib/crates/fabro-client/src/client.rs` (~lines 493-513) — + simplify `retrieve_resolved_server_settings` to drop `?view=resolved` + and the header check. +- Delete: `lib/crates/fabro-server/src/settings_view.rs` (module + tests). + This removes `SettingsApiView`, `SettingsQuery`, + `RESOLVED_VIEW_HEADER_NAME`, `RESOLVED_VIEW_HEADER_VALUE`, + `redact_for_api`, `redact_resolved_value`. +- Modify: `lib/crates/fabro-server/src/demo/mod.rs` (~lines 602-621 and + ~line 242) — the parallel demo settings handler imports + `settings_view::{SettingsQuery, SettingsApiView, RESOLVED_VIEW_HEADER_NAME}`. + Replace with direct serialization of the demo fixture in the new shape. +- Delete or rewrite every test still wired to the old view-toggle / + `X-Fabro-Settings-View` contract: + - `lib/crates/fabro-server/tests/it/api/settings.rs:63` and `:143` + (the entire + `retrieve_server_settings_resolved_view_returns_dense_settings_and_marker` + test goes away; layer-view test reshapes to the new single-shape + response). + - `lib/crates/fabro-server/tests/it/api/runs.rs:116` (redaction + assertion on `/runs/:id/settings` — the endpoint now returns the + layer unredacted). + - `lib/crates/fabro-server/src/server.rs:7491` (inline unit test + that issues `GET /settings?view=resolved` — rewrite to hit + `/settings` without the query parameter, or delete if it was only + covering the resolved view branch). + - `lib/crates/fabro-cli/tests/it/cmd/config.rs:923, :1008, :1011` + (CLI config-command integration tests mock + `/api/v1/settings?view=resolved` with an + `X-Fabro-Settings-View: resolved` response header; migrate the + mocks to the new single-shape contract — no `view` query param, + no custom header, body = typed `ServerSettings` fixture built via + `ServerSettings::from_layer(&server_settings_layer_fixture())`). +- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` — + `rendered_config` fetches the new `ServerSettings` from the endpoint + and merges with `UserSettings::resolve()` for a two-section display. +- Modify: `apps/fabro-web/app/routes/settings.tsx` — consume the newly + typed `ServerSettings` shape explicitly. +- Run: `scripts/refresh-fabro-spa.sh`. **Approach:** -- The endpoint's Rust handler path is selected via `operationId: - retrieveServerSettings` in the spec. Find the corresponding function in - `server.rs` and simplify it to `ServerSettings::resolve()` serialized - to JSON (after redaction). -- The OpenAPI schema rewrite should describe the full nested shape of - `ServerSettings` so generated clients get real types, not - `additionalProperties: true`. -- `fabro settings` display: two sections — one labeled something like - "user" (from local `UserSettings::resolve()`) and one labeled - "server" (from the API). Exact label/format is a minor UX call; keep - snapshot tests as the source of truth. +- The endpoint returns the in-memory `AppState.server_settings`. No + per-request disk read. +- OpenAPI conformance (`openapi_conformance.rs`) verifies the new + single-shape endpoint against the spec. -**Patterns to follow:** -- OpenAPI-first workflow per `CLAUDE.md`: edit the YAML, rebuild - fabro-api, run conformance tests. -- `bun run generate` workflow for the TypeScript client. -- `scripts/refresh-fabro-spa.sh` if the SPA bundle is affected. - -**Test scenarios:** -- *Happy path:* `GET /api/v1/settings` returns JSON with `server` and - `features` top-level keys; no other keys. -- *Happy path:* `server.listen` remains redacted in the response (same - policy as today). -- *Edge case:* The endpoint has no `view` query parameter; passing - `?view=layer` returns a 200 with the new dense shape (query param - silently ignored per OpenAPI's permissive defaults) *or* a 400 if the - router rejects unknown params — assert whichever matches the router's - current behavior for unknown params. -- *Edge case:* `fabro settings` output with a running server prints both - the user section and the server section; output is stable across runs. -- *Integration:* OpenAPI conformance test - (`openapi_conformance.rs`) passes — spec and router match. -- *Integration:* Generated TypeScript client's - `retrieveServerSettings` returns a typed object with `server` and - `features` fields. +**Test scenarios (canonical only):** +- *Contract:* `GET /api/v1/settings` returns JSON with exactly two + top-level keys, `server` and `features`. The typed shape matches the + OpenAPI `ServerSettings` schema. +- *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. +- *Behavior:* `server.listen` is present and visible in the main settings + response. +- *Integration:* OpenAPI conformance passes. +- *Integration:* `fabro settings` renders two sections (user / server) + without errors. **Verification:** - `cargo build -p fabro-api` succeeds (progenitor codegen clean). - `cargo nextest run -p fabro-server` passes, including conformance. - `cd lib/packages/fabro-api-client && bun run generate && bun run typecheck` succeeds. -- `fabro settings` (manual smoke test) displays a two-section layout - with no `SettingsLayer`-style sparse fields. -- CI's SPA-drift check passes (no uncommitted `lib/crates/fabro-spa/ - assets/` drift). +- CI's SPA-drift check passes. --- -- [ ] **Unit 8: Privatize merge internals; delete dead helpers** +- [ ] **Unit 8: Privatize merge internals** -**Goal:** Reduce the public surface of `fabro-config` to the three -context types and their `::resolve*()` methods. Delete free functions -and helpers whose callers have all migrated. +**Goal:** Minimize the public surface of `fabro-config`. Context types +and their constructors are the primary API; internal helpers go +`pub(crate)`. **Requirements:** R1, R8. **Dependencies:** Units 4, 5, 6. **Files:** -- Modify: `lib/crates/fabro-config/src/effective_settings.rs` — make - `materialize_settings_layer` `pub(crate)`. -- Modify: `lib/crates/fabro-config/src/lib.rs` — remove or privatize - `load_and_resolve` (it became a thin wrapper when the mode enum went - away; decide whether it's still worth keeping for tests or can be - deleted). -- Modify: `lib/crates/fabro-config/src/user.rs` — make - `load_settings_config` `pub(crate)`. -- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` — make - `resolve_server_from_file`, `resolve_cli_from_file`, - `resolve_project_from_file`, `resolve_workflow_from_file`, - `resolve_run_from_file`, `resolve_features_from_file` either - `pub(crate)` or fully private. Some may still need visibility for - tests or for the attach/runner code paths decided in Unit 6. -- Modify: `lib/crates/fabro-config/src/lib.rs` — update crate-level - re-exports to expose only `ServerSettings`, `UserSettings`, - `WorkflowSettings`, `EffectiveSettingsLayers` (as input type), and - error types. -- Note: `bin/dev/check-boundary.sh` grep-pattern updates already - landed in Unit 4 (moved earlier so the script never loses coverage - during the Unit 4 → 7 migration window). Unit 8 only verifies the - script still passes after the privatization pass. +- Modify: `lib/crates/fabro-config/src/effective_settings.rs` — + `materialize_settings_layer` → `pub(crate)`. +- `lib/crates/fabro-config/src/resolve/mod.rs` — all six + `resolve_*_from_file` functions (`project`, `workflow`, `run`, `cli`, + `server`, `features`) keep their existing `pub` visibility. Each has + at least one cross-crate consumer; see the KTD visibility decision + for the specific sites. +- Keep `pub`: `lib/crates/fabro-config/src/user.rs::load_settings_config` + — `fabro-server/src/serve.rs` loads the on-disk settings layer via + it. No visibility change. +- Modify: `lib/crates/fabro-config/src/lib.rs` — **settings-entrypoint + re-exports only**. Narrow scope: + - **Add:** `ServerSettings`, `UserSettings` (the new context types). + - **Remove:** the `Settings` type and the god-type-returning + `resolve` function from the public re-export list (both deleted in + Unit 6). + - **Leave untouched:** existing re-exports of `Error`, `Result`, + `Home`, `expand_tilde`, `apply_builtin_defaults`, + `defaults_layer`, `load_settings_*`, `parse_settings_layer`, the + `storage` module, and any other non-settings-entrypoint APIs. + These serve cross-workspace consumers (e.g., + `fabro-workflow/src/run_lookup.rs`, + `fabro-workflow/src/operations/create.rs`) whose API surface is + out of scope for this refactor. + - Update the *settings-entrypoint* paragraph in the crate-level + doc comment to describe the two context types and their + constructors as the primary resolution API. Do **not** rewrite + the full crate doc comment. -**Approach:** -- This is a cleanup pass. After Units 4–6, the grep-verified callers - are all internal; flipping visibility should compile cleanly. -- Prefer `pub(crate)` over fully private when the tests module in the - same crate still needs access. -- Document in the `fabro-config` crate-level doc comment what the public - surface is (three context types + their constructors + error types). - -**Patterns to follow:** -- Minimum-visibility convention in this codebase: start private, widen - only when a real caller needs it. +**Approach:** Cleanup pass. Flip visibility; compile. **Test scenarios:** -- *Happy path:* Downstream crates compile after the visibility - narrowing; no caller had a dependency that got cut. -- *Integration:* `bin/dev/check-boundary.sh` passes with updated grep - patterns. +- *Integration:* Downstream crates compile after visibility narrowing. **Verification:** -- `cargo build --workspace` succeeds. -- `cargo nextest run --workspace` passes. -- `grep -rn "pub fn resolve_.*_from_file" lib/crates/fabro-config/` - returns zero hits (all are now `pub(crate)` or private). +- `cargo build --workspace` and `cargo nextest run --workspace` pass. +- `grep -rn "pub fn resolve_\(project\|workflow\|run\|cli\|server\|features\)_from_file" lib/crates/fabro-config/` + returns six hits (all six per-namespace resolvers remain `pub` + because each has cross-crate consumers). - `bin/dev/check-boundary.sh` passes. ## System-Wide Impact -- **Interaction graph:** Every CLI command that reads user/server config - changes its import. ~20 call sites across `fabro-cli`, `fabro-server`, - and `fabro-install` migrate from free-function calls to context-type - constructors. Mechanical but broad. -- **Error propagation:** Today's resolver errors flow back unchanged from - `resolve_*_from_file`; the new constructors wrap the same error types - at crate boundaries. No new error categories. -- **State lifecycle risks:** None. The refactor preserves merge semantics - and the persisted `SettingsLayer` wire format for run manifests. -- **API surface parity:** `GET /api/v1/settings` changes shape (Unit 7). - The CLI is the only known in-tree consumer. External API consumers - that called `?view=resolved` will see a similar-shaped response - (now the only shape); external consumers that called `?view=layer` - see a different shape. The project is single-node and greenfield, so - this break is acceptable per prior decisions; surface it in release - notes regardless. -- **Integration coverage:** OpenAPI conformance test - (`lib/crates/fabro-server/tests/it/openapi_conformance.rs`) is the - primary cross-layer guardrail. Generated Rust types (via progenitor) - and TypeScript client (via openapi-generator) must both regenerate - cleanly. -- **Unchanged invariants:** TOML file format, namespace inventory (six - namespaces), namespace ownership rules (cli/server are owner-first), - layering precedence (user → project → workflow → args), server's - authority over server-owned fields, `fabro_cli::local_server` as the - single CLI-side gateway to `[server.*]` for lifecycle commands. +- **Interaction graph:** ~20 current-config call sites migrate from + `resolve_server_from_file(&layer)` to `ServerSettings::from_layer(&layer)` + (or `::resolve()`). Stored-layer reads unchanged. +- **Error propagation:** Resolver errors flow back unchanged; constructors + wrap today's error types. +- **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`). +- **Integration coverage:** OpenAPI conformance guards spec/router + alignment. Progenitor regen + `bun run generate` + SPA refresh is the + known hygiene. +- **Unchanged invariants:** TOML file format, namespace inventory, + layering precedence, server authority over server-owned fields, + `PreparedManifest.settings` wire format, `fabro_cli::local_server` as + the sanctioned CLI gateway to `[server.*]`. ## Risks & Dependencies | Risk | Mitigation | |------|------------| -| The 57-reference mechanical rename (Unit 3) lands incomplete, leaving compilation broken mid-merge | Use `cargo check --workspace` after each file batch; prefer a single atomic commit for Unit 3; if splitting, keep the rename consistent within each commit so the build stays green | -| OpenAPI response shape change breaks an external consumer we don't know about | Single-node greenfield app; release notes flag the break; spec change is captured in git history for discoverability | -| `fabro settings` snapshot tests drift in non-obvious ways after Unit 7's output restructuring | Use `cargo insta pending-snapshots` and review diffs before accepting; don't batch-accept across unrelated changes | -| TypeScript client regeneration (Unit 7) drifts from committed bundle, failing CI's `git diff --exit-code` check on `lib/crates/fabro-spa/assets/` | Run `scripts/refresh-fabro-spa.sh` after `bun run generate`; confirm the committed SPA matches source before pushing | -| `fabro run attach` verbosity replay breaks if the stored-layer access pattern is migrated awkwardly in Unit 6 | Keep the attach test that asserts "submit-time verbosity is preserved on attach" green throughout; if needed, keep `resolve_cli_from_file` as `pub(crate)` for this specific read path | -| `bin/dev/check-boundary.sh` grep patterns reference old free-function names and silently pass after the rename (false-negative boundary check) | Script update moved from Unit 8 into Unit 4 so the guard never loses coverage during the Unit 4 → 7 migration window. Unit 4 verification includes a throwaway-branch test confirming the script catches an unsanctioned `ServerSettings::resolve*` import | +| The ~57-reference rename (Unit 3) lands incomplete, breaking the build mid-merge | Atomic commit for Unit 3; `cargo check --workspace` between file batches | +| TypeScript regen drift fails CI's SPA-bundle check | Run `scripts/refresh-fabro-spa.sh` after `bun run generate` | +| `fabro settings` snapshot tests drift | `cargo insta pending-snapshots` + review per-snapshot before accept | +| `bin/dev/check-boundary.sh` silently passes after the rename | Script updated in Unit 4 (not Unit 8) so coverage never drops; verified on a throwaway branch | ## Documentation / Operational Notes -- Update `docs/api-reference/fabro-api.yaml` description prose for - `/api/v1/settings` to reflect the single dense response shape. -- Update any in-repo docs that reference `fabro settings --local` (if - any; grep `docs/` during Unit 1). -- `CLAUDE.md`'s "API workflow" section already describes the OpenAPI → - Rust → TypeScript pipeline; no changes needed there. -- Release notes (or changelog equivalent): call out the - `fabro settings --local` removal and the `GET /api/v1/settings` - response-shape change as deliberate breaking cleanups. +- OpenAPI description prose for `/api/v1/settings` updated to reflect the + single-shape response. +- `CLAUDE.md`'s "API workflow" section already describes the + OpenAPI → Rust → TypeScript pipeline; no changes needed. +- Release notes: `fabro settings --local` removed; `GET /api/v1/settings` + response shape changed; redaction removed. ## Sources & References @@ -1009,12 +994,8 @@ and helpers whose callers have all migrated. `lib/crates/fabro-types/src/settings/resolved.rs`, `lib/crates/fabro-cli/src/local_server.rs`, `lib/crates/fabro-server/src/run_manifest.rs`, - `lib/crates/fabro-server/src/settings_view.rs`, `docs/api-reference/fabro-api.yaml`. -- Related brainstorm (adjacent, not strict origin): +- Adjacent brainstorm: `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` - (especially R16 on owner-first namespace boundaries). -- Recent commits establishing the current boundary that this refactor - lifts to the type system: `5b1c40764` (CLI server-settings reads - restricted to `fabro_cli::local_server`), along with - `bin/dev/check-boundary.sh`. + (R16 on owner-first namespace boundaries). +- Boundary-enforcement commit: `5b1c40764` + `bin/dev/check-boundary.sh`. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 8a1f33e6e..bac80bc2e 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -947,13 +947,13 @@ fabro secret rm ANTHROPIC_API_KEY --- -## `fabro store dump` +## `fabro dump` -Export the contents of a run's store-backed state to a directory for debugging and inspection. +Export the contents of a run's durable state to a directory for debugging and inspection. ```bash -fabro store dump -fabro store dump abc123 -o ./debug-output +fabro dump +fabro dump abc123 -o ./debug-output ``` | Argument / Flag | Description | diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index d707afa35..f463697b3 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -30,18 +30,18 @@ These paths are local runtime state and caches, not the canonical run state. - **`runtime/`** — Local runtime files. Today this is mainly materialized blob payloads under `runtime/blobs/`. - **`nodes/{manager_node}_{visit}/child/`** — Nested scratch directories for manager-loop child workflows. -Large durable values, event streams, checkpoints, diffs, conclusions, and retros are no longer projected into live scratch by default. Use `fabro logs`, `fabro inspect`, the API, or `fabro store dump` for those surfaces. +Large durable values, event streams, checkpoints, diffs, conclusions, and retros are no longer projected into live scratch by default. Use `fabro logs`, `fabro inspect`, the API, or `fabro dump` for those surfaces. ## Reconstructed and export-only layouts -Reconstructed metadata branches and `fabro store dump` exports now use the same core layout: +Reconstructed metadata branches and `fabro dump` exports now use the same core layout: - `run.json` for the current projection snapshot, including the current checkpoint - `graph.fabro` for workflow source - `stages/retro/*.md` for retro prompt/response text - `stages/{node_id}@{visit}/...` for per-stage prompt, response, status, diff, stdout, and stderr files -`fabro store dump` adds export-only history surfaces on top of that shared layout: +`fabro dump` adds export-only history surfaces on top of that shared layout: - `events.jsonl` for the durable event stream - `checkpoints/*.json` for checkpoint history snapshots diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index cf3335ea6..fe476f2c2 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -15,6 +15,7 @@ wildcard_imports = "warn" [dependencies] chrono = { workspace = true, features = ["serde"] } +fabro-config = { path = "../fabro-config" } fabro-types = { path = "../fabro-types" } progenitor-client = "0.13" regress = "0.10" diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 605461af5..e52a0d83a 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -171,6 +171,137 @@ fn main() { "fabro_types::status::RunControlAction", &[], ), + ( + "RunStatusRecord", + "fabro_types::status::RunStatusRecord", + &[], + ), + ("ServerSettings", "fabro_config::ServerSettings", &[]), + ( + "ServerNamespace", + "fabro_types::settings::ServerNamespace", + &[], + ), + ( + "FeaturesNamespace", + "fabro_types::settings::FeaturesNamespace", + &[], + ), + ( + "ServerListenSettings", + "fabro_types::settings::server::ServerListenSettings", + &[], + ), + ( + "ServerApiSettings", + "fabro_types::settings::server::ServerApiSettings", + &[], + ), + ( + "ServerWebSettings", + "fabro_types::settings::server::ServerWebSettings", + &[], + ), + ( + "ServerAuthSettings", + "fabro_types::settings::server::ServerAuthSettings", + &[], + ), + ( + "ServerAuthMethod", + "fabro_types::settings::server::ServerAuthMethod", + &[], + ), + ( + "ServerAuthGithubSettings", + "fabro_types::settings::server::ServerAuthGithubSettings", + &[], + ), + ( + "ServerIpAllowlistSettings", + "fabro_types::settings::server::ServerIpAllowlistSettings", + &[], + ), + ( + "ServerIpAllowlistOverrideSettings", + "fabro_types::settings::server::ServerIpAllowlistOverrideSettings", + &[], + ), + ( + "IpAllowEntry", + "fabro_types::settings::server::IpAllowEntry", + &[], + ), + ( + "ServerStorageSettings", + "fabro_types::settings::server::ServerStorageSettings", + &[], + ), + ( + "ServerArtifactsSettings", + "fabro_types::settings::server::ServerArtifactsSettings", + &[], + ), + ( + "ServerSlateDbSettings", + "fabro_types::settings::server::ServerSlateDbSettings", + &[], + ), + ( + "ObjectStoreSettings", + "fabro_types::settings::server::ObjectStoreSettings", + &[], + ), + ( + "ServerSchedulerSettings", + "fabro_types::settings::server::ServerSchedulerSettings", + &[], + ), + ( + "ServerLoggingSettings", + "fabro_types::settings::server::ServerLoggingSettings", + &[], + ), + ( + "ServerIntegrationsSettings", + "fabro_types::settings::server::ServerIntegrationsSettings", + &[], + ), + ( + "GithubIntegrationSettings", + "fabro_types::settings::server::GithubIntegrationSettings", + &[], + ), + ( + "GithubIntegrationStrategy", + "fabro_types::settings::server::GithubIntegrationStrategy", + &[], + ), + ( + "SlackIntegrationSettings", + "fabro_types::settings::server::SlackIntegrationSettings", + &[], + ), + ( + "DiscordIntegrationSettings", + "fabro_types::settings::server::DiscordIntegrationSettings", + &[], + ), + ( + "TeamsIntegrationSettings", + "fabro_types::settings::server::TeamsIntegrationSettings", + &[], + ), + ( + "IntegrationWebhooksSettings", + "fabro_types::settings::server::IntegrationWebhooksSettings", + &[], + ), + ( + "WebhookStrategy", + "fabro_types::settings::server::WebhookStrategy", + &[], + ), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 69fd6c9ce..98893ff84 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -14,6 +14,17 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } pub mod types { + pub use fabro_config::ServerSettings; + pub use fabro_types::settings::server::{ + DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, + IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings, + ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, + ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, + ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, + ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, + TeamsIntegrationSettings, WebhookStrategy, + }; + pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; pub use fabro_types::status::{ BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus, }; diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/crates/fabro-api/tests/server_settings_round_trip.rs new file mode 100644 index 000000000..487c37f77 --- /dev/null +++ b/lib/crates/fabro-api/tests/server_settings_round_trip.rs @@ -0,0 +1,78 @@ +use std::any::{TypeId, type_name}; + +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_types::settings::server::ObjectStoreSettings; +use fabro_types::settings::{FeaturesNamespace, ServerNamespace}; + +#[test] +fn server_settings_family_reuses_domain_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn server_settings_json_matches_openapi_shape() { + let layer = parse_settings_layer( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://api.fabro.example.com" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.auth] +methods = ["dev-token", "github"] + +[server.auth.github] +allowed_usernames = ["alice"] + +[server.storage] +root = "/srv/fabro" + +[server.integrations.github] +enabled = true +strategy = "app" +app_id = "12345" +client_id = "Iv1.abcdef" +slug = "fabro-dev" + +[features] +session_sandboxes = true +"#, + ) + .expect("settings fixture should parse"); + let settings = ServerSettings::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"); + assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276"); + assert_eq!(json["server"]["storage"]["root"], "/srv/fabro"); + assert_eq!(json["features"]["session_sandboxes"], true); + + let round_trip: ApiServerSettings = + serde_json::from_value(json).expect("server 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/args.rs b/lib/crates/fabro-cli/src/args.rs index b8c92fddb..67ccdc5eb 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -514,7 +514,7 @@ pub(crate) struct InspectArgs { } #[derive(Args)] -pub(crate) struct StoreDumpArgs { +pub(crate) struct DumpArgs { #[command(flatten)] pub(crate) server: ServerTargetArgs, @@ -712,13 +712,6 @@ pub(crate) struct SystemEventsArgs { pub(crate) struct SettingsArgs { #[command(flatten)] pub(crate) target: ServerTargetArgs, - - /// Show only locally resolved settings and skip the server call - #[arg(long, conflicts_with = "server")] - pub(crate) local: bool, - - /// Optional workflow name, .fabro path, or .toml run config to overlay - pub(crate) workflow: Option, } #[derive(Args)] @@ -1008,8 +1001,8 @@ pub(crate) enum Commands { Parse(ParseArgs), /// Inspect and copy run artifacts (screenshots, reports, traces) Artifact(ArtifactNamespace), - /// Export store-backed run state for debugging - Store(StoreNamespace), + /// Export a run's durable state to a directory + Dump(DumpArgs), #[command(flatten)] RunsCmd(RunsCommands), /// List and test LLM models @@ -1092,9 +1085,7 @@ impl Commands { ArtifactCommand::List(_) => "artifact list", ArtifactCommand::Cp(_) => "artifact cp", }, - Self::Store(ns) => match &ns.command { - StoreCommand::Dump(_) => "store dump", - }, + Self::Dump(_) => "dump", Self::Exec(_) => "exec", Self::RunCmd(cmd) => cmd.name(), Self::Preflight(_) => "preflight", @@ -1206,18 +1197,6 @@ pub(crate) enum ArtifactCommand { Cp(ArtifactCpArgs), } -#[derive(Args)] -pub(crate) struct StoreNamespace { - #[command(subcommand)] - pub(crate) command: StoreCommand, -} - -#[derive(Subcommand)] -pub(crate) enum StoreCommand { - /// Export a run's durable state to a directory - Dump(StoreDumpArgs), -} - #[derive(Args)] pub(crate) struct SecretNamespace { #[command(flatten)] diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index c8f6635b8..b5351d640 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::merge::combine_files; +use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::CliLayer; -use fabro_types::settings::{CliSettings, SettingsLayer}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; @@ -33,24 +34,19 @@ pub(crate) struct CommandContext { cwd: PathBuf, base_config_path: PathBuf, machine_settings: SettingsLayer, - cli_settings: CliSettings, + user_settings: UserSettings, server_mode: ServerMode, server: OnceCell>, } impl CommandContext { - pub(crate) fn base( - printer: Printer, - cli_settings: CliSettings, - cli_layer: &CliLayer, - ) -> Result { - Self::new(printer, ServerMode::None, cli_settings, cli_layer) + pub(crate) fn base(printer: Printer, cli_layer: &CliLayer) -> Result { + Self::new(printer, ServerMode::None, cli_layer) } pub(crate) fn for_target( args: &ServerTargetArgs, printer: Printer, - cli_settings: CliSettings, cli_layer: &CliLayer, ) -> Result { Self::new( @@ -58,7 +54,6 @@ impl CommandContext { ServerMode::ByTarget { target_override: args.server.clone(), }, - cli_settings, cli_layer, ) } @@ -66,7 +61,6 @@ impl CommandContext { pub(crate) fn for_connection( args: &ServerConnectionArgs, printer: Printer, - cli_settings: CliSettings, cli_layer: &CliLayer, ) -> Result { Self::new( @@ -75,17 +69,11 @@ impl CommandContext { target_override: args.target.server.clone(), storage_dir_override: args.storage_dir.clone_path(), }, - cli_settings, cli_layer, ) } - fn new( - printer: Printer, - server_mode: ServerMode, - cli_settings: CliSettings, - cli_layer: &CliLayer, - ) -> Result { + fn new(printer: Printer, server_mode: ServerMode, cli_layer: &CliLayer) -> Result { let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); let disk_settings = match &server_mode { @@ -99,13 +87,14 @@ impl CommandContext { cli: Some(cli_layer.clone()), ..SettingsLayer::default() }); + let user_settings = fabro_config::UserSettings::from_layer(&machine_settings)?; Ok(Self { printer, cwd, base_config_path, machine_settings, - cli_settings, + user_settings, server_mode, server: OnceCell::new(), }) @@ -123,16 +112,12 @@ impl CommandContext { &self.cwd } - pub(crate) fn base_config_path(&self) -> &Path { - &self.base_config_path - } - pub(crate) fn machine_settings(&self) -> &SettingsLayer { &self.machine_settings } - pub(crate) fn cli_settings(&self) -> &CliSettings { - &self.cli_settings + pub(crate) fn user_settings(&self) -> &UserSettings { + &self.user_settings } pub(crate) async fn server(&self) -> Result> { diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index a465af4f7..2756912c1 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -16,7 +16,7 @@ use crate::shared::{print_json_pretty, split_run_path}; pub(super) async fn cp_command( args: &ArtifactCpArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -26,7 +26,6 @@ pub(super) async fn cp_command( run_id_selector, args.node.as_deref(), args.retry, - cli, cli_layer, printer, ) diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs index fefe8b61c..e7de1d4a4 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/list.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -1,7 +1,7 @@ use anyhow::Result; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -10,7 +10,7 @@ use crate::args::ArtifactListArgs; pub(super) async fn list_command( args: &ArtifactListArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -19,7 +19,6 @@ pub(super) async fn list_command( &args.run_id, args.node.as_deref(), args.retry, - cli, cli_layer, printer, ) diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index 56cfa6ad8..605d1beec 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -2,7 +2,7 @@ mod cp; mod list; use anyhow::{Context, Result}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::CliLayer; use fabro_types::{RunId, StageId}; use fabro_util::printer::Printer; @@ -26,11 +26,10 @@ pub(super) async fn resolve_artifacts( run_selector: &str, node: Option<&str>, retry: Option, - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) -> Result<(RunId, Client, Vec)> { - let ctx = CommandContext::for_target(server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_selector).await?.run_id; let mut entries = Vec::new(); @@ -65,7 +64,7 @@ pub(super) async fn resolve_artifacts( pub(crate) async fn dispatch( ns: ArtifactNamespace, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index 8ccf866ca..7968fb78e 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -4,7 +4,6 @@ use anyhow::{Context as _, Result, bail}; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore, StoredSubject}; use fabro_http::header::CONTENT_TYPE; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::browser; use fabro_util::printer::Printer; @@ -36,7 +35,6 @@ struct CliTokenSubject { pub(super) async fn login_command( args: AuthLoginArgs, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, @@ -45,7 +43,7 @@ pub(super) async fn login_command( #[cfg(not(unix))] { - let _ = (args, cli, cli_layer, printer); + let _ = (args, cli_layer, printer); bail!( "CLI OAuth login is not supported on Windows in this release. Use WSL, or use a dev-token server." ); @@ -53,7 +51,7 @@ pub(super) async fn login_command( #[cfg(unix)] { - let ctx = CommandContext::base(printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::base(printer, cli_layer)?; let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; let web_url = browser_origin(&target)?; let pkce = fabro_oauth::generate_pkce(); diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/crates/fabro-cli/src/commands/auth/logout.rs index 4b72ef5c1..6acf20275 100644 --- a/lib/crates/fabro-cli/src/commands/auth/logout.rs +++ b/lib/crates/fabro-cli/src/commands/auth/logout.rs @@ -1,7 +1,6 @@ use anyhow::{Result, bail}; use fabro_client::{AuthEntry, AuthStore}; use fabro_http::header::AUTHORIZATION; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; @@ -12,14 +11,13 @@ use crate::user_config::ServerTarget; pub(super) async fn logout_command( args: AuthLogoutArgs, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { require_no_json_override(process_local_json)?; - let ctx = CommandContext::base(printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::base(printer, cli_layer)?; let store = AuthStore::default(); if args.all { let entries = store.list()?; diff --git a/lib/crates/fabro-cli/src/commands/auth/mod.rs b/lib/crates/fabro-cli/src/commands/auth/mod.rs index 0cb79d6b4..dd4ca8a83 100644 --- a/lib/crates/fabro-cli/src/commands/auth/mod.rs +++ b/lib/crates/fabro-cli/src/commands/auth/mod.rs @@ -3,7 +3,6 @@ mod logout; mod status; use anyhow::Result; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; @@ -11,20 +10,19 @@ use crate::args::{AuthCommand, AuthNamespace}; pub(crate) async fn dispatch( ns: AuthNamespace, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { match ns.command { AuthCommand::Login(args) => { - login::login_command(args, cli, cli_layer, process_local_json, printer).await + login::login_command(args, cli_layer, process_local_json, printer).await } AuthCommand::Logout(args) => { - logout::logout_command(args, cli, cli_layer, process_local_json, printer).await + logout::logout_command(args, cli_layer, process_local_json, printer).await } AuthCommand::Status(args) => { - status::status_command(&args, cli, cli_layer, process_local_json, printer) + status::status_command(&args, cli_layer, process_local_json, printer) } } } diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/crates/fabro-cli/src/commands/auth/status.rs index de81e60c2..aa82b6f7e 100644 --- a/lib/crates/fabro-cli/src/commands/auth/status.rs +++ b/lib/crates/fabro-cli/src/commands/auth/status.rs @@ -1,7 +1,6 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore}; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format}; use fabro_util::printer::Printer; @@ -43,12 +42,11 @@ struct StatusOutput { pub(super) fn status_command( args: &AuthStatusArgs, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::base(printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::base(printer, cli_layer)?; let store = AuthStore::default(); let now = Utc::now(); let rows = if args.server.as_deref().is_some() { diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index b81434d66..1535bfa0e 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -8,155 +8,46 @@ )] use std::io::Write; -use std::path::Path; -use fabro_config::effective_settings::{ - EffectiveSettingsLayers, EffectiveSettingsMode, materialize_settings_layer, -}; -use fabro_config::{load_settings_project, project}; +use fabro_api::types::ServerSettings; +use fabro_config::UserSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliSettings, SettingsLayer}; use fabro_util::printer::Printer; -use serde_json::json; +use serde::Serialize; use crate::args::SettingsArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config; -fn config_layers( - ctx: &CommandContext, - workflow: Option<&Path>, -) -> anyhow::Result { - let cwd = ctx.cwd(); - let (workflow_layer, project_layer) = match workflow { - Some(path) => workflow_and_project_layers(path, cwd)?, - None => (SettingsLayer::default(), load_settings_project(cwd)?), - }; - let user_layer = - user_config::load_settings_with_config_and_storage_dir(Some(ctx.base_config_path()), None)?; - Ok(EffectiveSettingsLayers::new( - SettingsLayer::default(), - workflow_layer, - project_layer, - user_layer, - )) -} - -fn workflow_and_project_layers( - path: &Path, - cwd: &Path, -) -> anyhow::Result<(SettingsLayer, SettingsLayer)> { - let resolution = project::resolve_workflow_path(path, cwd)?; - if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { - anyhow::bail!( - "Workflow not found: {}", - resolution.resolved_workflow_path.display() - ); - } - - let workflow_layer = resolution.workflow_config.unwrap_or_default(); - let project_layer = project::discover_project_config( - resolution - .resolved_workflow_path - .parent() - .unwrap_or_else(|| Path::new(".")), - )? - .map(|(_, config)| config) - .unwrap_or_default(); - - Ok((workflow_layer, project_layer)) -} - -fn strip_nulls(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - for child in map.values_mut() { - strip_nulls(child); - } - map.retain(|_, child| !child.is_null()); - } - serde_json::Value::Array(values) => { - for child in values { - strip_nulls(child); - } - } - _ => {} - } -} - -fn local_settings_value( - args: &SettingsArgs, - cli: &CliSettings, - cli_layer: &CliLayer, - printer: Printer, -) -> anyhow::Result { - let base_ctx = CommandContext::base(printer, cli.clone(), cli_layer)?; - let layers = config_layers(&base_ctx, args.workflow.as_deref())?; - let local_settings = - materialize_settings_layer(layers, None, EffectiveSettingsMode::LocalOnly)?; - let mut value = resolve_local_settings_value(&local_settings)?; - strip_nulls(&mut value); - Ok(value) -} - -fn render_resolve_errors(errors: Vec) -> anyhow::Error { - anyhow::anyhow!( - "failed to resolve local settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) -} - -fn resolve_local_settings_value(file: &SettingsLayer) -> anyhow::Result { - let file = fabro_config::apply_builtin_defaults(file.clone()); - - let project = fabro_config::resolve_project_from_file(&file).map_err(render_resolve_errors)?; - let workflow = - fabro_config::resolve_workflow_from_file(&file).map_err(render_resolve_errors)?; - let run = fabro_config::resolve_run_from_file(&file).map_err(render_resolve_errors)?; - let cli = fabro_config::resolve_cli_from_file(&file).map_err(render_resolve_errors)?; - let features = - fabro_config::resolve_features_from_file(&file).map_err(render_resolve_errors)?; - - Ok(json!({ - "project": project, - "workflow": workflow, - "run": run, - "cli": cli, - "features": features, - })) +#[derive(Serialize)] +struct RenderedConfig { + user: UserSettings, + server: ServerSettings, } async fn rendered_config( args: &SettingsArgs, - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) -> anyhow::Result { - if args.local { - return local_settings_value(args, cli, cli_layer, printer); - } - if args.workflow.is_some() { - anyhow::bail!("WORKFLOW requires --local; use `fabro settings --local WORKFLOW`"); - } - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; - ctx.server() + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let user = fabro_config::UserSettings::resolve()?; + let server = ctx + .server() .await? .retrieve_resolved_server_settings() - .await + .await?; + serde_json::to_value(RenderedConfig { user, server }).map_err(Into::into) } pub(crate) async fn execute( args: &SettingsArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> anyhow::Result<()> { - let config = Box::pin(rendered_config(args, cli, cli_layer, printer)).await?; + let config = Box::pin(rendered_config(args, cli_layer, printer)).await?; if cli.output.format == OutputFormat::Json { print_json_pretty(&config)?; return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index cdf9931ac..85c8517c6 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, @@ -143,7 +143,7 @@ fn render_report(report: &CheckReport, styles: &Styles, verbose: bool, printer: pub(crate) async fn run_doctor( args: &DoctorArgs, verbose: bool, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result { @@ -179,7 +179,7 @@ pub(crate) async fn run_doctor( }], }; - let ctx = match CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer) { + let ctx = match CommandContext::for_target(&args.target, printer, cli_layer) { Ok(ctx) => ctx, Err(err) => { report.sections.push(CheckSection { diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/dump.rs similarity index 98% rename from lib/crates/fabro-cli/src/commands/store/dump.rs rename to lib/crates/fabro-cli/src/commands/dump.rs index f29b56e00..2dc98b4b4 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/dump.rs @@ -1,6 +1,6 @@ #![expect( clippy::disallowed_methods, - reason = "CLI `store dump` command: sync file I/O for dump outputs" + reason = "CLI `dump` command: sync file I/O for dump outputs" )] use std::io::ErrorKind; @@ -11,28 +11,28 @@ use bytes::Bytes; #[cfg(test)] use fabro_store::{ArtifactStore, RunDatabase}; use fabro_store::{EventEnvelope, RunProjection, StageId}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::{RunBlobId, RunId}; use fabro_util::printer::Printer; +use fabro_workflow::run_dump::RunDump; use futures::future::BoxFuture; #[cfg(test)] use serde::de::DeserializeOwned; use tokio::task::spawn_blocking; -use super::run_export::StoreRunExport; -use crate::args::StoreDumpArgs; +use crate::args::DumpArgs; use crate::command_context::CommandContext; use crate::server_client::Client; use crate::shared::{absolute_or_current, print_json_pretty}; -pub(crate) async fn dump_command( - args: &StoreDumpArgs, - cli: &CliSettings, +pub(crate) async fn run( + args: &DumpArgs, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; @@ -222,7 +222,7 @@ async fn export_run_from_source( .with_context(|| format!("failed to create {}", staging_parent.display()))?; let staging_dir = tempfile::Builder::new() - .prefix(".fabro-store-dump-") + .prefix(".fabro-dump-") .tempdir_in(staging_parent) .with_context(|| { format!( @@ -248,7 +248,7 @@ async fn write_run_dump( output_dir: &Path, ) -> Result { let events = source.list_events().await?; - let mut dump = StoreRunExport::from_store_state_and_events(state, &events)?; + let mut dump = RunDump::from_store_state_and_events(state, &events)?; dump.hydrate_referenced_blobs_with_reader(|blob_id| source.read_blob(blob_id)) .await?; diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 9680e605b..4048915e5 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -15,7 +15,7 @@ use fabro_llm::types::{ use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::McpEntryLayer; -use fabro_types::settings::{CliSettings, InterpString}; +use fabro_types::settings::{CliNamespace, InterpString}; use fabro_util::exit::{ErrorExt, ExitClass}; use fabro_util::printer::Printer; use futures::stream; @@ -358,7 +358,7 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter { pub(crate) async fn execute( mut args: ExecArgs, - cli: &CliSettings, + cli: &CliNamespace, _printer: Printer, ) -> AnyResult<()> { use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 7f7b21e93..71ffa9f68 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -14,7 +14,7 @@ use fabro_api::types; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tracing::debug; @@ -28,7 +28,7 @@ use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, r pub(crate) async fn run( args: &GraphArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, @@ -37,7 +37,7 @@ pub(crate) async fn run( require_no_json_override(process_local_json)?; } - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 3197f4c79..a401b1843 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -24,7 +24,7 @@ use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_f use fabro_config::bind::Bind; use fabro_config::daemon::ServerDaemon; use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir}; -use fabro_config::{ResolveError, Storage, envfile}; +use fabro_config::{Storage, envfile}; use fabro_install::{ InstallListenConfig, PendingSettingsWrite, merge_server_settings as merge_server_settings_impl, persist_install_outputs_direct, write_github_app_settings, write_token_settings, @@ -34,7 +34,7 @@ use fabro_server::serve; use fabro_store::ArtifactStore; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::settings::server::ServerAuthMethod; -use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; @@ -1262,24 +1262,12 @@ fn persist_github_install_changes( Ok(()) } -fn render_server_resolve_errors(errors: Vec) -> anyhow::Error { - anyhow::anyhow!( - "failed to resolve server settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) -} - async fn write_artifact_store_metadata( settings: &SettingsLayer, fabro_version: &str, ) -> Result<()> { - let resolved = - fabro_config::resolve_server_from_file(settings).map_err(render_server_resolve_errors)?; - let (object_store, prefix) = serve::build_artifact_object_store(&resolved)?; + let resolved = fabro_config::ServerSettings::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?; Ok(()) @@ -1421,7 +1409,7 @@ where pub(crate) async fn execute( args: &InstallArgs, command: Option, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, @@ -1437,7 +1425,7 @@ pub(crate) async fn execute( async fn run_install_github_command( args: &InstallArgs, github_args: &InstallGithubArgs, - cli: &CliSettings, + cli: &CliNamespace, process_local_json: bool, printer: Printer, ) -> Result<()> { @@ -1600,7 +1588,7 @@ async fn run_install_github_inner( pub(crate) async fn run_install( args: &InstallArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, @@ -1626,7 +1614,7 @@ pub(crate) async fn run_install( async fn run_install_inner( args: &InstallArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -1802,8 +1790,7 @@ async fn run_install_inner( .context("failed to parse generated settings.toml")?, args.storage_dir.as_deref(), ); - fabro_config::resolve_server_from_file(&install_settings) - .map_err(render_server_resolve_errors)?; + fabro_config::ServerSettings::from_layer(&install_settings)?; // Secrets and auth material { diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 68eece40f..f9408d15b 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod artifact; pub(crate) mod auth; pub(crate) mod config; pub(crate) mod doctor; +pub(crate) mod dump; pub(crate) mod exec; pub(crate) mod graph; pub(crate) mod install; @@ -10,6 +11,7 @@ pub(crate) mod parse; pub(crate) mod pr; pub(crate) mod preflight; pub(crate) mod provider; +pub(crate) mod rebuild; pub(crate) mod render_graph; pub(crate) mod repo; pub(crate) mod run; @@ -17,7 +19,6 @@ pub(crate) mod runs; pub(crate) mod sandbox; pub(crate) mod secret; pub(crate) mod server; -pub(crate) mod store; pub(crate) mod system; pub(crate) mod uninstall; pub(crate) mod upgrade; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 61b22b490..d7c26c2a5 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -3,7 +3,7 @@ use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; use fabro_model::{Catalog, Model, Provider}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -42,7 +42,7 @@ struct ModelTestOutput { pub(crate) async fn execute( command: Option, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -51,7 +51,7 @@ pub(crate) async fn execute( ModelsCommand::List(args) => &args.target, ModelsCommand::Test(args) => &args.target, }; - let ctx = CommandContext::for_target(target_args, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(target_args, printer, cli_layer)?; let server = ctx.server().await?; run_models(command, &server, cli.output.format == OutputFormat::Json).await diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index bc0a21306..e14d970ce 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -11,13 +11,13 @@ use std::io::Write; use fabro_config::project::resolve_workflow; use fabro_graphviz::parser::parse_ast; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_util::printer::Printer; use crate::args::ParseArgs; use crate::shared::read_workflow_file; -pub(crate) fn run(args: &ParseArgs, _cli: &CliSettings, _printer: Printer) -> anyhow::Result<()> { +pub(crate) fn run(args: &ParseArgs, _cli: &CliNamespace, _printer: Printer) -> anyhow::Result<()> { let stdout = std::io::stdout(); run_to(args, stdout.lock()) } diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 1732c6556..c48132ba0 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::info; @@ -9,14 +9,14 @@ use crate::shared::print_json_pretty; pub(super) async fn close_command( args: PrCloseArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli, cli_layer, printer).await?; + super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; - let creds = super::load_github_credentials_required(cli, cli_layer, printer)?; + let creds = super::load_github_credentials_required(cli_layer, printer)?; fabro_github::close_pull_request( &creds, diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 3e269ba79..3f3c8c2d2 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -5,7 +5,7 @@ use fabro_auth::configured_providers_from_process_env; use fabro_config::Storage; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_vault::Vault; @@ -16,7 +16,7 @@ use tracing::info; use crate::args::PrCreateArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +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; @@ -27,11 +27,11 @@ use crate::user_config; )] pub(super) async fn create_command( args: PrCreateArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let events = client.list_run_events(&run_id, None, None).await?; @@ -86,7 +86,7 @@ pub(super) async fn create_command( let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url) .map_err(|err| anyhow::anyhow!("{err}"))?; - let creds = super::load_github_credentials_required(cli, cli_layer, printer)?; + let creds = super::load_github_credentials_required(cli_layer, printer)?; let branch_found = fabro_github::branch_exists( &creds, diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index bcd497a12..bf86031eb 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,7 +1,7 @@ use anyhow::Result; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -25,11 +25,11 @@ struct PrRow { pub(super) async fn list_command( args: PrListArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let mut entries = Vec::new(); @@ -50,7 +50,7 @@ pub(super) async fn list_command( return Ok(()); } - let creds = super::load_github_credentials_required(cli, cli_layer, printer)?; + let creds = super::load_github_credentials_required(cli_layer, printer)?; let futures: Vec<_> = entries .iter() diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 7e68240c8..3ee3f3338 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::info; @@ -9,14 +9,14 @@ use crate::shared::print_json_pretty; pub(super) async fn merge_command( args: PrMergeArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli, cli_layer, printer).await?; + super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; - let creds = super::load_github_credentials_required(cli, cli_layer, printer)?; + let creds = super::load_github_credentials_required(cli_layer, printer)?; fabro_github::merge_pull_request( &creds, diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index bf7a3671b..dd70c83f7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -9,7 +9,7 @@ use fabro_config::Storage; use fabro_github::GitHubCredentials; use fabro_types::PullRequestRecord; use fabro_types::settings::cli::CliLayer; -use fabro_types::settings::{CliSettings, InterpString}; +use fabro_types::settings::{CliNamespace, InterpString}; use fabro_util::printer::Printer; use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; @@ -22,7 +22,7 @@ const GITHUB_CREDENTIALS_REQUIRED: &str = pub(crate) async fn dispatch( ns: PrNamespace, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -42,28 +42,19 @@ pub(crate) async fn dispatch( reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] fn load_github_credentials_required( - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) -> Result { - let ctx = CommandContext::base(printer, cli.clone(), cli_layer)?; - let server_settings = - fabro_config::resolve_server_from_file(ctx.machine_settings()).map_err(|errors| { - anyhow!( - "failed to resolve server settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) - })?; + let ctx = CommandContext::base(printer, cli_layer)?; + let server_settings = fabro_config::ServerSettings::from_layer(ctx.machine_settings()) + .map_err(anyhow::Error::from)?; let vault = user_config::storage_dir(ctx.machine_settings()) .ok() .and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok()); let creds = build_github_credentials( - server_settings.integrations.github.strategy, + server_settings.server.integrations.github.strategy, server_settings + .server .integrations .github .app_id @@ -79,11 +70,10 @@ fn load_github_credentials_required( pub(crate) async fn load_pr_record( server: &ServerTargetArgs, run_id: &str, - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) -> Result<(PullRequestRecord, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index f093838dc..d766e7dd4 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::info; @@ -9,14 +9,14 @@ use crate::shared::print_json_pretty; pub(super) async fn view_command( args: PrViewArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli, cli_layer, printer).await?; + super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; - let creds = super::load_github_credentials_required(cli, cli_layer, printer)?; + let creds = super::load_github_credentials_required(cli_layer, printer)?; let detail = fabro_github::get_pull_request( &creds, diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 6ebba6168..1a49e8cb5 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,7 +1,7 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -17,12 +17,12 @@ use crate::shared::print_json_pretty; pub(crate) async fn execute( mut args: PreflightArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; let manifest = build_run_manifest(ManifestBuildInput { diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 45b33e9f9..86a393a09 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -1,7 +1,6 @@ use anyhow::Result; use fabro_api::types; use fabro_auth::credential_id_for; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -12,14 +11,13 @@ use crate::shared::provider_auth; pub(super) async fn login_command( args: ProviderLoginArgs, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { require_no_json_override(process_local_json)?; let s = Styles::detect_stderr(); - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let server = ctx.server().await?; let credential = if args.api_key_stdin { provider_auth::authenticate_provider_with_api_key_source( diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs index 10db120ae..07e3d9adf 100644 --- a/lib/crates/fabro-cli/src/commands/provider/mod.rs +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -1,7 +1,6 @@ mod login; use anyhow::Result; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; @@ -9,14 +8,13 @@ use crate::args::{ProviderCommand, ProviderNamespace}; pub(crate) async fn dispatch( ns: ProviderNamespace, - cli: &CliSettings, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { match ns.command { ProviderCommand::Login(args) => { - login::login_command(args, cli, cli_layer, process_local_json, printer).await + login::login_command(args, cli_layer, process_local_json, printer).await } } } diff --git a/lib/crates/fabro-cli/src/commands/store/rebuild.rs b/lib/crates/fabro-cli/src/commands/rebuild.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/store/rebuild.rs rename to lib/crates/fabro-cli/src/commands/rebuild.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index 6d7ccd294..8ed8ae707 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; -pub(crate) fn run_deinit(cli: &CliSettings, printer: Printer) -> Result> { +pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result> { let repo_root = super::init::git_repo_root()?; let mut removed = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 136eb2bff..6a9ba0d28 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tokio::process::Command as TokioCommand; @@ -36,7 +36,7 @@ pub(super) fn git_repo_root() -> Result { pub(crate) async fn run_init( args: &RepoInitArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result> { @@ -151,7 +151,7 @@ draft = true } if cli.output.format != OutputFormat::Json { - check_github_app_installation(&args.target, cli, cli_layer, printer).await; + check_github_app_installation(&args.target, cli_layer, printer).await; } Ok(created) @@ -159,7 +159,6 @@ draft = true async fn check_github_app_installation( target: &ServerTargetArgs, - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) { @@ -200,7 +199,7 @@ async fn check_github_app_installation( return; // Not a GitHub repo — skip silently }; - let ctx = match CommandContext::for_target(target, printer, cli.clone(), cli_layer) { + let ctx = match CommandContext::for_target(target, printer, cli_layer) { Ok(ctx) => ctx, Err(err) => { fabro_util::printerr!( diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 30ce0fd41..5ddeaa95b 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -2,7 +2,7 @@ pub(crate) mod deinit; pub(crate) mod init; use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -11,7 +11,7 @@ use crate::shared::print_json_pretty; pub(crate) async fn dispatch( ns: RepoNamespace, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 7b7fe3ca0..712f75b2d 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -19,7 +19,6 @@ use anyhow::Result; use fabro_api::types; use fabro_interview::{AnswerValue, ConsoleInterviewer, Question, QuestionOption, QuestionType}; use fabro_store::EventEnvelope; -use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::run::ApprovalMode; use fabro_types::{EventBody, RunId}; use fabro_util::json::normalize_json_value; @@ -49,6 +48,7 @@ pub(crate) async fn attach_run( kill_on_detach: bool, styles: &'static Styles, json_output: bool, + live_verbose: bool, ) -> Result { let inferred_storage_dir = infer_storage_dir(run_dir); let inferred_run_id = infer_run_id(run_dir); @@ -63,6 +63,7 @@ pub(crate) async fn attach_run( kill_on_detach, styles, json_output, + live_verbose, Printer::Default, )) .await; @@ -79,6 +80,7 @@ pub(crate) async fn attach_run_with_client( kill_on_detach: bool, styles: &'static Styles, json_output: bool, + live_verbose: bool, printer: Printer, ) -> Result { let state = client.get_run_state(run_id).await?; @@ -86,10 +88,6 @@ pub(crate) async fn attach_run_with_client( fabro_config::resolve_run_from_file(&record.settings) .is_ok_and(|settings| settings.execution.approval == ApprovalMode::Auto) }); - let verbose = state.spec.as_ref().is_some_and(|record| { - fabro_config::resolve_cli_from_file(&record.settings) - .is_ok_and(|settings| settings.output.verbosity == OutputVerbosity::Verbose) - }); 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)); @@ -98,7 +96,7 @@ pub(crate) async fn attach_run_with_client( if state_is_terminal(&state) || initial_exit_code.is_some() { return replay_run_with_client( - verbose, + live_verbose, events, initial_exit_code .or(state_exit_code) @@ -116,7 +114,7 @@ pub(crate) async fn attach_run_with_client( styles, AttachOptions { auto_approve, - verbose, + verbose: live_verbose, kill_on_detach, json_output, }, @@ -544,6 +542,7 @@ mod tests { false, no_color_styles(), false, + false, )) .await .unwrap_err(); diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index ffb83ce03..20572cf2b 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -11,17 +11,17 @@ use crate::user_config::load_settings_with_storage_dir; pub(crate) async fn execute( mut args: RunArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let cli_defaults = load_settings_with_storage_dir(None)?; args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; let quiet = args.detach; - let prevent_idle_sleep = ctx.cli_settings().exec.prevent_idle_sleep; + let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep; let created_run = Box::pin(super::create::create_run( &ctx, &args, @@ -64,6 +64,7 @@ pub(crate) async fn execute( true, styles, json, + ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 732312219..84c05258e 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tokio::fs; @@ -28,7 +28,7 @@ enum CopyDirection { pub(crate) async fn cp_command( args: CpArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -41,8 +41,7 @@ pub(crate) async fn cp_command( local_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli, cli_layer, printer) - .await?; + resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; let file_count = if args.recursive { Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?) @@ -73,8 +72,7 @@ pub(crate) async fn cp_command( remote_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli, cli_layer, printer) - .await?; + resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; let file_count = if args.recursive { Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?) @@ -128,11 +126,10 @@ fn parse_direction(src: &str, dst: &str) -> Result { async fn resolve_client_and_run_id( server: &ServerTargetArgs, run_prefix: &str, - cli: &CliSettings, cli_layer: &CliLayer, printer: Printer, ) -> Result<(Client, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_prefix).await?.run_id; Ok((client.clone_for_reuse(), run_id)) diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 32951fff1..51da824cc 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -10,7 +10,7 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::{debug, info}; @@ -22,12 +22,12 @@ use crate::shared::print_json_pretty; pub(crate) async fn run( args: DiffArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index b4f8c81e4..ba988198e 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -9,19 +9,19 @@ use git2::Repository; use crate::args::ForkArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +use crate::commands::rebuild::rebuild_run_store; use crate::shared::print_json_pretty; use crate::shared::repo::ensure_matching_repo_origin; pub(crate) async fn run( args: &ForkArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 53c817aff..879d3f68b 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -13,7 +13,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::json::normalize_json_value; use fabro_util::printer::Printer; @@ -32,11 +32,11 @@ const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500); pub(crate) async fn run( args: &LogsArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Showing logs"); diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index b9a8eaec0..7acd35e3e 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::CliNamespace; +use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -29,7 +29,7 @@ pub(crate) mod wait; pub(crate) async fn dispatch( cmd: RunCommands, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, _process_local_json: bool, printer: Printer, @@ -39,7 +39,7 @@ pub(crate) async fn dispatch( RunCommands::Create(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let cli_defaults = load_settings_with_storage_dir(None)?; - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let created_run = Box::pin(create::create_run( &ctx, &args, @@ -57,7 +57,7 @@ pub(crate) async fn dispatch( Ok(()) } RunCommands::Start(StartArgs { server, run }) => { - let ctx = CommandContext::for_target(&server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; start::start_run_with_client(client.as_ref(), &run_id, false).await?; @@ -68,7 +68,7 @@ pub(crate) async fn dispatch( } RunCommands::Attach(AttachArgs { server, run }) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; let exit_code = Box::pin(attach::attach_run_with_client( @@ -77,6 +77,7 @@ pub(crate) async fn dispatch( false, styles, cli.output.format == OutputFormat::Json, + ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, printer, )) .await?; @@ -112,9 +113,8 @@ pub(crate) async fn dispatch( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let ctx = - CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; - crate::sleep_inhibitor::guard(ctx.cli_settings().exec.prevent_idle_sleep) + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep) }; Box::pin(resume::resume_command( args, styles, cli, cli_layer, printer, diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index 55a9f8c0d..f344ffd76 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::info; @@ -10,12 +10,12 @@ use crate::shared::print_json_pretty; pub(crate) async fn run( args: PreviewArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let expires_in_secs = diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 85d801474..9c892ea14 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,5 +1,5 @@ -use fabro_types::settings::CliSettings; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::CliNamespace; +use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -15,11 +15,11 @@ use crate::shared::print_json_pretty; pub(crate) async fn resume_command( args: ResumeArgs, styles: &'static Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; @@ -39,6 +39,7 @@ pub(crate) async fn resume_command( true, styles, json, + ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index a6d32406d..2793cbdcf 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -3,7 +3,7 @@ use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_checkpoint::git::Store; use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::{EventBody, RunEvent}; use fabro_util::printer::Printer; @@ -17,7 +17,7 @@ use serde::Serialize; use crate::args::RewindArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +use crate::commands::rebuild::rebuild_run_store; use crate::server_client::Client; use crate::shared::repo::ensure_matching_repo_origin; use crate::shared::{color_if, print_json_pretty}; @@ -33,12 +33,12 @@ pub(crate) struct TimelineEntryJson { pub(crate) async fn run( args: &RewindArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 898ce27ae..b587fb3a1 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -4,6 +4,7 @@ std::io::BufReader; not on a Tokio path" )] +use std::collections::HashMap; use std::io::{BufRead as StdBufRead, BufReader as StdBufReader}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -107,6 +108,7 @@ pub(crate) async fn execute( artifact_sink, run_control: Some(run_control), github_app, + github_permissions: HashMap::new(), vault, on_node: None, registry_override: None, diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 4e9268de1..d3ce4f9f8 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::info; @@ -10,7 +10,7 @@ use crate::shared::print_json_pretty; pub(crate) async fn run( args: SshArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, @@ -19,7 +19,7 @@ pub(crate) async fn run( require_no_json_override(process_local_json)?; } - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let ssh = client.create_run_ssh_access(&run_id, args.ttl).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index cd63a54bb..26bf2d181 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -11,7 +11,7 @@ use std::io::Write; use anyhow::{Result, bail}; use fabro_types::RunId; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -27,11 +27,11 @@ use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind}; pub(crate) async fn run( args: &WaitArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Waiting for run to complete"); diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index 4a0627759..dcc996fc9 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -11,11 +11,11 @@ use crate::shared::print_json_pretty; pub(crate) async fn archive_command( args: &RunsArchiveArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; run_bulk( Action::Archive, &args.runs, @@ -28,11 +28,11 @@ pub(crate) async fn archive_command( pub(crate) async fn unarchive_command( args: &RunsUnarchiveArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; run_bulk( Action::Unarchive, &args.runs, @@ -66,7 +66,7 @@ async fn run_bulk( action: Action, identifiers: &[String], client: &server_client::Client, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let json = cli.output.format == OutputFormat::Json; diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 347292a73..dfde883ae 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; use fabro_workflow::run_status::RunStatus; @@ -21,13 +20,8 @@ pub(crate) struct InspectOutput { pub sandbox: Option, } -pub(crate) async fn run( - args: &InspectArgs, - cli: &CliSettings, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; +pub(crate) async fn run(args: &InspectArgs, cli_layer: &CliLayer, printer: Printer) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let client = ctx.server().await?; let run = ServerRunSummaryInfo::from_summary(client.resolve_run(&args.run).await?); let run_id = run.run_id(); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index ef3c2392b..c26f4c73e 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -4,7 +4,7 @@ use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -20,11 +20,11 @@ use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; pub(crate) async fn list_command( args: &RunsListArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/crates/fabro-cli/src/commands/runs/mod.rs index f3900a94e..3804d4052 100644 --- a/lib/crates/fabro-cli/src/commands/runs/mod.rs +++ b/lib/crates/fabro-cli/src/commands/runs/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -13,7 +13,7 @@ pub(crate) mod rm; pub(crate) async fn dispatch( cmd: RunsCommands, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { @@ -23,7 +23,7 @@ pub(crate) async fn dispatch( list::list_command(&args, &styles, cli, cli_layer, printer).await } RunsCommands::Rm(args) => rm::remove_command(&args, cli, cli_layer, printer).await, - RunsCommands::Inspect(args) => inspect::run(&args, cli, cli_layer, printer).await, + RunsCommands::Inspect(args) => inspect::run(&args, cli_layer, printer).await, RunsCommands::Archive(args) => { archive::archive_command(&args, cli, cli_layer, printer).await } diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index becdc9969..8990d4efc 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -11,18 +11,18 @@ use crate::shared::print_json_pretty; pub(crate) async fn remove_command( args: &RunsRemoveArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; remove_from(args, ctx.server().await?.as_ref(), cli, printer).await } async fn remove_from( args: &RunsRemoveArgs, client: &server_client::Client, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let json = cli.output.format == OutputFormat::Json; diff --git a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs index c584f3e81..3adc7bd13 100644 --- a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs +++ b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; @@ -7,7 +7,7 @@ use crate::args::SandboxCommand; pub(crate) async fn dispatch( command: SandboxCommand, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, process_local_json: bool, printer: Printer, diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index 033afdf17..25d7f7680 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -2,7 +2,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -25,7 +25,7 @@ fn format_age(dt: DateTime, now: DateTime) -> String { pub(super) async fn list_command( client: &Client, _args: &SecretListArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let secrets = client.list_secrets().await?; diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index d0148c60e..19fdeef73 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -3,7 +3,7 @@ mod rm; mod set; use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; @@ -12,11 +12,11 @@ use crate::command_context::CommandContext; pub(crate) async fn dispatch( ns: SecretNamespace, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_target(&ns.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&ns.target, printer, cli_layer)?; let server = ctx.server().await?; match ns.command { SecretCommand::List(args) => list::list_command(&server, &args, cli, printer).await, diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index 43c466501..253ca09d0 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; @@ -10,7 +10,7 @@ use crate::shared::print_json_pretty; pub(super) async fn rm_command( client: &Client, args: &SecretRmArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { client.delete_secret_by_name(&args.key).await?; diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index a8c88debd..1ec66418e 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -11,7 +11,7 @@ use std::io::{IsTerminal, Read as _}; use anyhow::{Context as _, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use tokio::task::spawn_blocking; @@ -60,7 +60,7 @@ async fn resolve_value(args: &SecretSetArgs) -> Result { pub(super) async fn set_command( client: &Client, args: &SecretSetArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let value = resolve_value(args).await?; diff --git a/lib/crates/fabro-cli/src/commands/store/mod.rs b/lib/crates/fabro-cli/src/commands/store/mod.rs deleted file mode 100644 index f4cab294d..000000000 --- a/lib/crates/fabro-cli/src/commands/store/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub(crate) mod dump; -pub(crate) mod rebuild; -mod run_export; - -use anyhow::Result; -use fabro_types::settings::CliSettings; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; - -use crate::args::{StoreCommand, StoreNamespace}; - -pub(crate) async fn dispatch( - ns: StoreNamespace, - cli: &CliSettings, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - match ns.command { - StoreCommand::Dump(args) => dump::dump_command(&args, cli, cli_layer, printer).await, - } -} diff --git a/lib/crates/fabro-cli/src/commands/store/run_export.rs b/lib/crates/fabro-cli/src/commands/store/run_export.rs deleted file mode 100644 index 42738384d..000000000 --- a/lib/crates/fabro-cli/src/commands/store/run_export.rs +++ /dev/null @@ -1 +0,0 @@ -pub(super) use fabro_workflow::run_dump::RunDump as StoreRunExport; diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index ae6e30343..67a099a2e 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -13,11 +13,11 @@ use crate::shared::{format_size, print_json_pretty}; pub(super) async fn df_command( args: &DfArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; let server = ctx.server().await?; let json = cli.output.format == OutputFormat::Json; diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs index da5bdad57..1e8d69346 100644 --- a/lib/crates/fabro-cli/src/commands/system/events.rs +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -1,6 +1,6 @@ use anyhow::Result; use fabro_client::sse; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use futures::StreamExt; @@ -10,11 +10,11 @@ use crate::command_context::CommandContext; pub(super) async fn events_command( args: &SystemEventsArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; let server = ctx.server().await?; let mut stream = server.attach_events(&args.run_ids).await?; let mut pending = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/crates/fabro-cli/src/commands/system/info.rs index 17668bfe5..314b3f347 100644 --- a/lib/crates/fabro-cli/src/commands/system/info.rs +++ b/lib/crates/fabro-cli/src/commands/system/info.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; @@ -9,11 +9,11 @@ use crate::shared::print_json_pretty; pub(super) async fn info_command( args: &SystemInfoArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; let server = ctx.server().await?; let response = server.get_system_info().await?; diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs index 86d35e367..45f35dee6 100644 --- a/lib/crates/fabro-cli/src/commands/system/mod.rs +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -4,7 +4,7 @@ mod info; mod prune; use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::CliLayer; use fabro_util::printer::Printer; pub(crate) use prune::parse_duration; @@ -13,7 +13,7 @@ use crate::args::{SystemCommand, SystemNamespace}; pub(crate) async fn dispatch( ns: SystemNamespace, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 1ed6d684d..376ae2003 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use anyhow::{Context, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::{debug, info}; @@ -13,11 +13,11 @@ use crate::shared::{format_size, print_json_pretty}; pub(super) async fn prune_command( args: &RunsPruneArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; let server = ctx.server().await?; let response = server .prune_runs(types::PruneRunsRequest { diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index ed358496d..5d19aa459 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -15,7 +15,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use fabro_config::Storage; use fabro_config::daemon::ServerDaemon; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::Home; use fabro_util::printer::Printer; @@ -45,7 +45,7 @@ struct Inventory { )] pub(crate) async fn run_uninstall( args: &UninstallArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let json = cli.output.format == OutputFormat::Json; diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index ddfaaf659..53390cfd5 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -13,7 +13,7 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use semver::Version; @@ -436,7 +436,7 @@ impl UpgradeCheckState { pub(crate) async fn run_upgrade( args: UpgradeArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let current_exe = std::env::current_exe() @@ -599,7 +599,7 @@ pub(crate) async fn run_upgrade( fn run_upgrade_brew( args: &UpgradeArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, channel: BrewChannel, ) -> Result<()> { @@ -1153,7 +1153,7 @@ mod tests { #[test] fn run_upgrade_brew_refuses_by_default() { - let cli = CliSettings::default(); + let cli = CliNamespace::default(); let err = run_upgrade_brew( &brew_args(None, false, false, false), &cli, @@ -1169,7 +1169,7 @@ mod tests { #[test] fn run_upgrade_brew_dry_run_returns_ok() { - let cli = CliSettings::default(); + let cli = CliNamespace::default(); let result = run_upgrade_brew( &brew_args(None, false, false, true), &cli, @@ -1181,7 +1181,7 @@ mod tests { #[test] fn run_upgrade_brew_rejects_version_flag() { - let cli = CliSettings::default(); + let cli = CliNamespace::default(); let err = run_upgrade_brew( &brew_args(Some("0.1.0"), false, false, false), &cli, @@ -1194,7 +1194,7 @@ mod tests { #[test] fn run_upgrade_brew_rejects_prerelease_flag() { - let cli = CliSettings::default(); + let cli = CliNamespace::default(); let err = run_upgrade_brew( &brew_args(None, true, false, false), &cli, @@ -1207,7 +1207,7 @@ mod tests { #[test] fn run_upgrade_brew_rejects_force_flag() { - let cli = CliSettings::default(); + let cli = CliNamespace::default(); let err = run_upgrade_brew( &brew_args(None, false, true, false), &cli, diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index a530e5470..74bba2d11 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -2,7 +2,7 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -15,11 +15,11 @@ use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; pub(crate) async fn run( args: &ValidateArgs, styles: &Styles, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/crates/fabro-cli/src/commands/version.rs index a58e0541f..993557834 100644 --- a/lib/crates/fabro-cli/src/commands/version.rs +++ b/lib/crates/fabro-cli/src/commands/version.rs @@ -6,7 +6,7 @@ use std::io::IsTerminal; use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use serde_json::{Map, Value, json}; @@ -18,12 +18,12 @@ use crate::user_config::{self, ServerTarget}; pub(crate) async fn version_command( args: &VersionArgs, - cli: &CliSettings, + cli: &CliNamespace, cli_layer: &CliLayer, printer: Printer, ) -> Result<()> { let client = client_info(); - let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?; + let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; let server_address = format_server_target(&server_target); let server_info = match 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 845508a2a..6a4f7b5f8 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -7,7 +7,7 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use fabro_config::project::{discover_project_config, resolve_fabro_root}; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; @@ -16,7 +16,7 @@ use crate::shared::{print_json_pretty, relative_path}; pub(super) fn create_command( args: &WorkflowCreateArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let cwd = std::env::current_dir()?; diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index 44eee6f46..40da91203 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -5,7 +5,7 @@ use fabro_config::project::{ WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed, resolve_fabro_root, }; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -17,7 +17,7 @@ const GOAL_MAX_LEN: usize = 60; pub(super) fn list_command( _args: &WorkflowListArgs, - cli: &CliSettings, + cli: &CliNamespace, printer: Printer, ) -> Result<()> { let styles = Styles::detect_stderr(); diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/crates/fabro-cli/src/commands/workflow/mod.rs index 46478dd2c..44de6cf4e 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/mod.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/mod.rs @@ -2,12 +2,12 @@ mod create; mod list; use anyhow::Result; -use fabro_types::settings::CliSettings; +use fabro_types::settings::CliNamespace; use fabro_util::printer::Printer; use crate::args::{WorkflowCommand, WorkflowNamespace}; -pub(crate) fn dispatch(ns: WorkflowNamespace, cli: &CliSettings, printer: Printer) -> Result<()> { +pub(crate) fn dispatch(ns: WorkflowNamespace, cli: &CliNamespace, printer: Printer) -> Result<()> { match ns.command { WorkflowCommand::List(args) => list::list_command(&args, cli, printer), WorkflowCommand::Create(args) => create::create_command(&args, cli, printer), diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index 687360db2..72bcfc576 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -34,8 +34,8 @@ pub(crate) fn bind_request( } pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec { - fabro_config::resolve_server_from_file(settings) - .map(|resolved| resolved.auth.methods) + fabro_config::ServerSettings::from_layer(settings) + .map(|resolved| resolved.server.auth.methods) .unwrap_or_default() } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 5a5ffd7fb..816a9ac73 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -173,9 +173,9 @@ async fn main_inner() -> (String, Result<()>) { cli: Some(cli_layer.clone()), ..SettingsLayer::default() }); - let cli_settings = match user_config::resolve_cli_settings(&combined_settings) { - Ok(cli_settings) => cli_settings, - Err(err) => return (command_name, Err(err)), + let cli_settings = match fabro_config::UserSettings::from_layer(&combined_settings) { + Ok(settings) => settings.cli, + Err(err) => return (command_name, Err(err.into())), }; let printer = printer_from_verbosity(cli_settings.output.verbosity); @@ -247,8 +247,8 @@ async fn main_inner() -> (String, Result<()>) { Commands::Artifact(ns) => { commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?; } - Commands::Store(ns) => { - commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + Commands::Dump(args) => { + commands::dump::run(&args, &cli_settings, &cli_layer, printer).await?; } Commands::RunsCmd(cmd) => { commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?; @@ -318,14 +318,7 @@ async fn main_inner() -> (String, Result<()>) { commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?; } Commands::Auth(ns) => { - commands::auth::dispatch( - ns, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + commands::auth::dispatch(ns, &cli_layer, process_local_json, printer).await?; } Commands::Pr(ns) => { Box::pin(commands::pr::dispatch( @@ -353,14 +346,7 @@ async fn main_inner() -> (String, Result<()>) { commands::upgrade::run_upgrade(args, &cli_settings, printer).await?; } Commands::Provider(ns) => { - commands::provider::dispatch( - ns, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + commands::provider::dispatch(ns, &cli_layer, process_local_json, printer).await?; } Commands::Sandbox { command } => { commands::sandbox::dispatch( @@ -519,7 +505,7 @@ async fn prepare_server_bootstrap( mod tests { use args::{ AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand, - ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace, + ProviderCommand, ProviderNamespace, }; use tokio::runtime::Runtime; @@ -954,13 +940,11 @@ level = "warn" } #[test] - fn parse_store_dump_command() { - let cli = Cli::try_parse_from(["fabro", "store", "dump", "ABC123", "-o", "./out"]) - .expect("should parse"); + fn parse_dump_command() { + let cli = + Cli::try_parse_from(["fabro", "dump", "ABC123", "-o", "./out"]).expect("should parse"); match *cli.command.unwrap() { - Commands::Store(StoreNamespace { - command: StoreCommand::Dump(args), - }) => { + Commands::Dump(args) => { assert_eq!(args.run, "ABC123"); assert_eq!(args.output, std::path::PathBuf::from("./out")); } @@ -1077,36 +1061,22 @@ level = "warn" assert_eq!(cli.command.as_ref().unwrap().name(), "settings"); match *cli.command.unwrap() { Commands::Settings(args) => { - assert!(!args.local); assert!(args.target.server.is_none()); - assert!(args.workflow.is_none()); } _ => panic!("unexpected command variant"), } } #[test] - fn parse_settings_with_workflow() { - let cli = Cli::try_parse_from(["fabro", "settings", "demo"]).expect("should parse"); - match *cli.command.unwrap() { - Commands::Settings(args) => { - assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo"))); - } - _ => panic!("unexpected command variant"), - } + fn parse_settings_rejects_workflow_argument() { + let result = Cli::try_parse_from(["fabro", "settings", "demo"]); + assert!(result.is_err(), "should reject settings workflow argument"); } #[test] - fn parse_settings_local_mode() { - let cli = - Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse"); - match *cli.command.unwrap() { - Commands::Settings(args) => { - assert!(args.local); - assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo"))); - } - _ => panic!("unexpected command variant"), - } + fn parse_settings_rejects_local_flag() { + let result = Cli::try_parse_from(["fabro", "settings", "--local"]); + assert!(result.is_err(), "should reject settings --local"); } #[test] diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 0dcf265e7..6131c2870 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -5,7 +5,7 @@ use anyhow::Result; pub(crate) use fabro_client::ServerTarget; pub(crate) use fabro_config::user::*; use fabro_types::settings::cli::CliTargetSettings; -use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::version::FABRO_VERSION; use tracing::debug; @@ -30,41 +30,9 @@ pub(crate) fn load_settings_with_config_and_storage_dir( Ok(apply_storage_dir_override(layer, storage_dir)) } -fn render_resolve_errors(errors: Vec) -> anyhow::Error { - anyhow::anyhow!( - "failed to resolve cli settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) -} - -pub(crate) fn resolve_cli_settings(file: &SettingsLayer) -> anyhow::Result { - fabro_config::resolve_cli_from_file(file).map_err(render_resolve_errors) -} - -pub(crate) 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 -} - /// 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: &CliSettings) -> Option { +fn cli_target_from_settings(settings: &CliNamespace) -> Option { let target = settings.target.as_ref()?; match target { CliTargetSettings::Http { url } => Some(url.as_source()), @@ -73,8 +41,8 @@ fn cli_target_from_settings(settings: &CliSettings) -> Option { } fn configured_server_target(settings: &SettingsLayer) -> Result> { - let cli_settings = resolve_cli_settings(settings)?; - let Some(value) = cli_target_from_settings(&cli_settings) else { + let user_settings = fabro_config::UserSettings::from_layer(settings)?; + let Some(value) = cli_target_from_settings(&user_settings.cli) else { return Ok(None); }; parse_server_target(&value).map(Some) diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index ddf031666..28dc1db55 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -40,93 +40,10 @@ fn parse_settings(stdout: &[u8]) -> serde_json::Value { serde_yaml::from_slice(stdout).expect("stdout should be valid YAML settings") } -fn parse_settings_json(stdout: &[u8]) -> serde_json::Value { - serde_json::from_slice(stdout).expect("stdout should be valid JSON settings") -} - -fn run_goal_inline(settings: &serde_json::Value) -> Option<&str> { - let goal = settings.get("run")?.get("goal")?; - (goal.get("type")?.as_str() == Some("inline")) - .then(|| goal.get("value")?.as_str()) - .flatten() -} - -fn run_model_name(settings: &serde_json::Value) -> Option<&str> { - settings.get("run")?.get("model")?.get("name")?.as_str() -} - -fn run_model_provider(settings: &serde_json::Value) -> Option<&str> { - settings.get("run")?.get("model")?.get("provider")?.as_str() -} - -fn run_inputs(settings: &serde_json::Value) -> &serde_json::Map { - settings - .get("run") - .and_then(|run| run.get("inputs")) - .and_then(serde_json::Value::as_object) - .expect("run.inputs") -} - -fn run_sandbox(settings: &serde_json::Value) -> &serde_json::Value { - settings - .get("run") - .and_then(|run| run.get("sandbox")) - .expect("run.sandbox") -} - -fn run_checkpoint(settings: &serde_json::Value) -> &serde_json::Value { - settings - .get("run") - .and_then(|run| run.get("checkpoint")) - .expect("run.checkpoint") -} - -fn run_hooks(settings: &serde_json::Value) -> &[serde_json::Value] { - settings - .get("run") - .and_then(|run| run.get("hooks")) - .and_then(serde_json::Value::as_array) - .expect("run.hooks") -} - -fn run_agent_mcps(settings: &serde_json::Value) -> &serde_json::Map { - settings - .get("run") - .and_then(|run| run.get("agent")) - .and_then(|agent| agent.get("mcps")) - .and_then(serde_json::Value::as_object) - .expect("run.agent.mcps") -} - -fn auto_approve_enabled(settings: &serde_json::Value) -> bool { - settings - .get("run") - .and_then(|run| run.get("execution")) - .and_then(|execution| execution.get("approval")) - .and_then(serde_json::Value::as_str) - == Some("auto") -} - -fn run_prepare_commands(settings: &serde_json::Value) -> Vec { - settings - .get("run") - .and_then(|run| run.get("prepare")) - .and_then(|prepare| prepare.get("commands")) - .and_then(serde_json::Value::as_array) - .expect("run.prepare.commands") - .iter() - .map(|value| { - value - .as_str() - .expect("command should be a string") - .to_string() - }) - .collect() -} - fn server_storage_root(settings: &serde_json::Value) -> &str { settings .get("server") + .and_then(|server| server.get("server")) .and_then(|server| server.get("storage")) .and_then(|storage| storage.get("root")) .and_then(serde_json::Value::as_str) @@ -157,7 +74,7 @@ shared = "server" } fn resolved_server_settings_fixture() -> serde_json::Value { - let settings = fabro_config::resolve(&server_settings_layer_fixture()) + let settings = fabro_config::ServerSettings::from_layer(&server_settings_layer_fixture()) .expect("server settings fixture should resolve"); serde_json::to_value(settings).expect("resolved settings payload should serialize") } @@ -389,155 +306,6 @@ script = "workflow-setup" // Tests // --------------------------------------------------------------------------- -#[test] -fn settings_local_merges_cli_and_project_defaults() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - - let output = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success() - .get_output() - .stdout - .clone(); - - let cfg = parse_settings(&output); - assert!(cfg.get("_version").is_none()); - assert_eq!(cfg["project"]["directory"].as_str(), Some(".")); - assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro")); - assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt")); - assert_eq!(cfg["run"]["sandbox"]["provider"].as_str(), Some("daytona")); - assert_eq!(run_model_name(&cfg), Some("project-model")); - assert_eq!(run_model_provider(&cfg), Some("openai")); - assert_eq!(run_goal_inline(&cfg), None); - - // v2 R22: run.inputs replaces the inherited map wholesale rather than - // merging by key, so the project layer wipes out the CLI layer's inputs. - let vars = run_inputs(&cfg); - assert_eq!( - vars.get("project_only").and_then(serde_json::Value::as_str), - Some("1") - ); - assert_eq!( - vars.get("shared").and_then(serde_json::Value::as_str), - Some("project") - ); - assert!( - !vars.contains_key("cli_only"), - "run.inputs should replace across layers, not merge by key" - ); - - // v2 R71: provider-native maps such as run.sandbox.daytona.labels remain - // sticky merge-by-key, so CLI labels persist under the project layer. - let sandbox = run_sandbox(&cfg); - let labels = &sandbox["daytona"]["labels"]; - assert_eq!(labels["cli_only"].as_str(), Some("1")); - assert_eq!(labels["shared"].as_str(), Some("cli")); -} - -#[test] -fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - - let output = context - .settings() - .current_dir(project.path()) - .args(["--local", "demo"]) - .assert() - .success() - .get_output() - .stdout - .clone(); - - let cfg = parse_settings(&output); - assert_eq!(run_goal_inline(&cfg), Some("demo goal")); - assert_eq!(run_model_name(&cfg), Some("run-model")); - assert_eq!(run_model_provider(&cfg), Some("anthropic")); - - // v2 R22: run.inputs replaces wholesale, so the workflow layer wins - // over project and cli. - let vars = run_inputs(&cfg); - assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1")); - assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run")); - - // checkpoint.exclude_globs is a security/policy list: replace by default. - let checkpoint = run_checkpoint(&cfg); - assert_eq!( - checkpoint["exclude_globs"], - serde_json::json!(["run-only", "shared"]) - ); - - // Hooks: id-based replacement. The "shared" hook appears in both cli and - // workflow layers and resolves to the workflow entry; project and run-only - // contribute the other two ids. - let hooks = run_hooks(&cfg); - assert!(hooks.len() >= 2); - let shared_hook = hooks - .iter() - .find(|hook| hook["name"].as_str() == Some("shared")) - .expect("shared hook"); - assert_eq!(shared_hook["command"].as_str(), Some("echo run")); - assert!( - hooks - .iter() - .any(|hook| hook["name"].as_str() == Some("run-only")) - ); - - let mcps = run_agent_mcps(&cfg); - let shared = mcps.get("shared").expect("shared mcp"); - assert_eq!(shared["transport"]["type"].as_str(), Some("stdio")); - assert_eq!( - shared["transport"]["command"], - serde_json::json!(["echo", "run"]) - ); - assert!(mcps.contains_key("run_only")); - - // run.sandbox.daytona.labels stays sticky merge-by-key per R71. - let sandbox = run_sandbox(&cfg); - let labels = &sandbox["daytona"]["labels"]; - assert_eq!(labels["run_only"].as_str(), Some("1")); - assert_eq!(labels["shared"].as_str(), Some("run")); - - // run.sandbox.env stays sticky merge-by-key per R71. - let env = &sandbox["env"]; - assert_eq!(env["CLI_ONLY"].as_str(), Some("1")); - assert_eq!(env["RUN_ONLY"].as_str(), Some("1")); - assert_eq!(env["SHARED"].as_str(), Some("run")); -} - -#[test] -fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { - let mut context = test_context!(); - let (project, _storage_dir) = setup_external_workflow_fixture(&mut context); - let cwd = tempfile::tempdir().unwrap(); - let workflow = project.path().join("workflow.toml"); - - // Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml - let output = context - .settings() - .env_remove("FABRO_STORAGE_DIR") - .current_dir(cwd.path()) - .args(["--local", workflow.to_str().unwrap()]) - .assert() - .success() - .get_output() - .stdout - .clone(); - - let cfg = parse_settings(&output); - assert!(auto_approve_enabled(&cfg)); - // v2 R30: run.prepare.steps replaces the whole ordered list across layers. - // The highest-precedence layer (workflow) wins. - assert_eq!(run_prepare_commands(&cfg), vec![ - "workflow-setup".to_string() - ]); - assert_eq!(run_sandbox(&cfg)["preserve"].as_bool(), Some(true)); -} - #[test] fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { let mut context = test_context!(); @@ -608,254 +376,6 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { ); } -#[test] -fn settings_fabro_path_matches_ambient_defaults() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - - let ambient = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success() - .get_output() - .stdout - .clone(); - let graph = context - .settings() - .current_dir(project.path()) - .args(["--local", "standalone.fabro"]) - .assert() - .success() - .get_output() - .stdout - .clone(); - - assert_eq!(parse_settings(&graph), parse_settings(&ambient)); -} - -#[test] -fn settings_missing_run_config_errors() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - - let mut cmd = context.settings(); - cmd.current_dir(project.path()); - cmd.args(["--local", "missing.toml"]); - let output = cmd.output().expect("command should execute"); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("workflow not found:"), - "stderr should report missing workflow path, got:\n{stderr}" - ); - assert!( - stderr.contains("missing.toml"), - "stderr should include missing workflow filename, got:\n{stderr}" - ); -} - -#[test] -fn settings_legacy_cli_config_is_silently_ignored() { - let context = test_context!(); - let project = tempfile::tempdir().unwrap(); - - context.write_home( - ".fabro/cli.toml", - r#" -_version = 1 - -[cli.output] -verbosity = "verbose" - -[run.model] -name = "legacy-model" -"#, - ); - - let assert = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success(); - - assert!( - assert.get_output().stderr.is_empty(), - "settings should not warn about legacy config files: {}", - String::from_utf8_lossy(&assert.get_output().stderr) - ); - - let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal")); - assert!( - cfg["run"]["model"].get("name").is_none(), - "resolved dense settings should omit an unset run.model.name" - ); -} - -#[test] -fn settings_legacy_user_config_is_silently_ignored() { - let context = test_context!(); - let project = tempfile::tempdir().unwrap(); - - context.write_home( - ".fabro/user.toml", - r#" -_version = 1 - -[cli.output] -verbosity = "verbose" - -[run.model] -name = "legacy-model" -"#, - ); - - let assert = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success(); - - assert!( - assert.get_output().stderr.is_empty(), - "settings should not warn about legacy config files: {}", - String::from_utf8_lossy(&assert.get_output().stderr) - ); - - let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal")); - assert!( - cfg["run"]["model"].get("name").is_none(), - "resolved dense settings should omit an unset run.model.name" - ); -} - -#[test] -fn settings_legacy_server_config_is_silently_ignored() { - let context = test_context!(); - let project = tempfile::tempdir().unwrap(); - - context.write_home( - ".fabro/server.toml", - r#" -_version = 1 - -[cli.output] -verbosity = "verbose" - -[run.model] -name = "legacy-model" -"#, - ); - - let assert = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success(); - - assert!( - assert.get_output().stderr.is_empty(), - "settings should not warn about legacy config files: {}", - String::from_utf8_lossy(&assert.get_output().stderr) - ); - - let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal")); - assert!( - cfg["run"]["model"].get("name").is_none(), - "resolved dense settings should omit an unset run.model.name" - ); -} - -#[test] -fn settings_user_config_wins_over_legacy_cli_config() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - context.write_home( - ".fabro/cli.toml", - r#" -_version = 1 - -[run.model] -name = "legacy-model" - -[run.inputs] -shared = "legacy" -"#, - ); - - let assert = context - .settings() - .arg("--local") - .current_dir(project.path()) - .assert() - .success(); - - assert!( - assert.get_output().stderr.is_empty(), - "settings should not warn about legacy config files: {}", - String::from_utf8_lossy(&assert.get_output().stderr) - ); - - let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(run_model_name(&cfg), Some("project-model")); - let vars = run_inputs(&cfg); - assert_eq!( - vars.get("shared").and_then(serde_json::Value::as_str), - Some("project") - ); -} - -#[test] -fn settings_uses_fabro_home_for_home_config_resolution() { - let context = test_context!(); - let fabro_home = tempfile::tempdir().unwrap(); - - std::fs::write( - fabro_home.path().join("settings.toml"), - r#" -_version = 1 - -[cli.output] -verbosity = "verbose" - -[run.model] -name = "from-fabro-home" -"#, - ) - .unwrap(); - - let output = context - .settings() - .args(["--local", "--json"]) - .env("FABRO_HOME", fabro_home.path()) - .env_remove("FABRO_STORAGE_DIR") - .output() - .expect("command should execute"); - - assert!( - output.status.success(), - "settings command failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let cfg = parse_settings_json(&output.stdout); - assert!(cfg.get("_version").is_none()); - assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose")); - assert_eq!( - cfg["run"]["model"]["name"].as_str(), - Some("from-fabro-home") - ); -} - #[test] fn settings_rejects_server_url_flag() { let context = test_context!(); @@ -883,32 +403,27 @@ fn settings_rejects_storage_dir_flag() { } #[test] -fn settings_rejects_local_and_server_combination() { +fn settings_rejects_local_flag() { let context = test_context!(); context .settings() - .args(["--local", "--server", "https://cli.example.com"]) + .arg("--local") .assert() .failure() .stderr(predicate::str::contains( - "the argument '--local' cannot be used with '--server '", + "unexpected argument '--local' found", )); } #[test] -fn settings_rejects_workflow_without_local() { +fn settings_rejects_workflow_argument() { let context = test_context!(); - let project = setup_settings_fixture(&context); - context .settings() - .current_dir(project.path()) .arg("demo") .assert() .failure() - .stderr(predicate::str::contains( - "WORKFLOW requires --local; use `fabro settings --local WORKFLOW`", - )); + .stderr(predicate::str::contains("unexpected argument 'demo' found")); } #[test] @@ -918,12 +433,9 @@ fn settings_fetches_server_resolved_settings() { let server = MockServer::start(); let server_settings = resolved_server_settings_fixture(); let mock = server.mock(|when, then| { - when.method("GET") - .path("/api/v1/settings") - .query_param("view", "resolved"); + when.method("GET").path("/api/v1/settings"); then.status(200) .header("Content-Type", "application/json") - .header("X-Fabro-Settings-View", "resolved") .body(server_settings_body(&server_settings)); }); context.write_home( @@ -962,30 +474,25 @@ shared = "cli" mock.assert(); let cfg = parse_settings(&output); - assert!(cfg.get("_version").is_none()); - assert_eq!(cfg["project"]["directory"].as_str(), Some(".")); - assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro")); - assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt")); - assert_eq!(run_model_name(&cfg), Some("server-model")); - assert_eq!(run_model_provider(&cfg), Some("openai")); + assert_eq!( + cfg["user"]["cli"]["output"]["verbosity"].as_str(), + Some("verbose") + ); + assert_eq!( + cfg["user"]["features"]["session_sandboxes"].as_bool(), + Some(false) + ); + assert_eq!( + cfg["server"]["server"]["auth"]["methods"][0].as_str(), + Some("dev-token") + ); assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); - assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal")); - - // Server-backed mode now returns the selected server's own dense resolved - // settings; local project/user overlays are not merged into the output. - let vars = run_inputs(&cfg); assert_eq!( - vars.get("server_only").and_then(serde_json::Value::as_str), - Some("1") - ); - assert_eq!( - vars.get("shared").and_then(serde_json::Value::as_str), - Some("server") - ); - assert!( - !vars.contains_key("project_only"), - "server-backed settings output must not include local workflow/project overlays" + cfg["server"]["server"]["artifacts"]["store"]["type"].as_str(), + Some("local") ); + assert!(cfg.get("run").is_none()); + assert!(cfg.get("project").is_none()); } #[test] @@ -994,21 +501,16 @@ fn settings_cli_server_target_overrides_configured_server_target() { let project = setup_settings_fixture(&context); let configured_server = MockServer::start(); let configured_mock = configured_server.mock(|when, then| { - when.method("GET") - .path("/api/v1/settings") - .query_param("view", "resolved"); + when.method("GET").path("/api/v1/settings"); then.status(500) .body("configured-server-should-not-be-used"); }); let cli_server = MockServer::start(); let cli_server_settings = resolved_server_settings_fixture(); let cli_mock = cli_server.mock(|when, then| { - when.method("GET") - .path("/api/v1/settings") - .query_param("view", "resolved"); + when.method("GET").path("/api/v1/settings"); then.status(200) .header("Content-Type", "application/json") - .header("X-Fabro-Settings-View", "resolved") .body(server_settings_body(&cli_server_settings)); }); context.write_home( @@ -1044,46 +546,6 @@ verbosity = "verbose" assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); } -#[test] -fn settings_errors_when_server_lacks_resolved_view_marker() { - let context = test_context!(); - let project = setup_settings_fixture(&context); - let server = MockServer::start(); - let server_settings = resolved_server_settings_fixture(); - let mock = server.mock(|when, then| { - when.method("GET") - .path("/api/v1/settings") - .query_param("view", "resolved"); - then.status(200) - .header("Content-Type", "application/json") - .body(server_settings_body(&server_settings)); - }); - context.write_home( - ".fabro/settings.toml", - format!( - r#" -_version = 1 - -[cli.target] -type = "http" -url = "{}/api/v1" -"#, - server.base_url() - ), - ); - - context - .settings() - .current_dir(project.path()) - .assert() - .failure() - .stderr(predicate::str::contains( - "server does not support resolved settings view; upgrade the server or use --local", - )); - - mock.assert(); -} - #[test] fn settings_unreachable_http_target_fails_clearly() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index fb768cea3..d1d0bb551 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -8,7 +8,7 @@ use crate::support::{fabro_json_snapshot, unique_run_id}; fn resolved_run( settings: &fabro_types::settings::SettingsLayer, -) -> fabro_types::settings::RunSettings { +) -> fabro_types::settings::RunNamespace { fabro_config::resolve_run_from_file(settings).expect("run settings should resolve") } diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/dump.rs similarity index 89% rename from lib/crates/fabro-cli/tests/it/cmd/store_dump.rs rename to lib/crates/fabro-cli/tests/it/cmd/dump.rs index 1fedcf9e0..355dc7f0c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/dump.rs @@ -16,14 +16,14 @@ use crate::support::{LightweightCli, unique_run_id}; fn help() { let context = test_context!(); let mut cmd = context.command(); - cmd.args(["store", "dump", "--help"]); + cmd.args(["dump", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Export a run's durable state to a directory - Usage: fabro store dump [OPTIONS] --output + Usage: fabro dump [OPTIONS] --output Arguments: Run ID prefix or workflow name @@ -42,7 +42,7 @@ fn help() { } #[test] -fn store_dump_accepts_server_target_from_separate_home() { +fn dump_accepts_server_target_from_separate_home() { let context = test_context!(); let run = setup_completed_dry_run(&context); let cli = LightweightCli::new(); @@ -51,7 +51,6 @@ fn store_dump_accepts_server_target_from_separate_home() { let mut cmd = cli.command(); cmd.args([ - "store", "dump", "--server", &server, @@ -63,10 +62,10 @@ fn store_dump_accepts_server_target_from_separate_home() { cmd.env("FABRO_DEV_TOKEN", dev_token); } - let output = cmd.output().expect("store dump should execute"); + let output = cmd.output().expect("dump should execute"); assert!( output.status.success(), - "store dump via remote server target failed\nstdout:\n{}\nstderr:\n{}", + "dump via remote server target failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -74,7 +73,7 @@ fn store_dump_accepts_server_target_from_separate_home() { } #[test] -fn store_dump_exports_large_command_output_backed_by_blob_refs() { +fn dump_exports_large_command_output_backed_by_blob_refs() { let context = test_context!(); let workflow = context.temp_dir.join("large-output.fabro"); fs::write( @@ -130,17 +129,11 @@ fn store_dump_exports_large_command_output_backed_by_blob_refs() { let output_dir = context.temp_dir.join("export"); let mut dump_cmd = context.command(); - dump_cmd.args([ - "store", - "dump", - "--output", - output_dir.to_str().unwrap(), - &run_id, - ]); - let dump_output = dump_cmd.output().expect("store dump should execute"); + dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); + let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), - "store dump failed\nstdout:\n{}\nstderr:\n{}", + "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); @@ -153,7 +146,7 @@ fn store_dump_exports_large_command_output_backed_by_blob_refs() { } #[test] -fn store_dump_exports_blob_refs_and_artifacts_together() { +fn dump_exports_blob_refs_and_artifacts_together() { let context = test_context!(); let workspace_dir = context.temp_dir.join("mixed-export"); fs::create_dir_all(&workspace_dir).unwrap(); @@ -233,17 +226,11 @@ include = ["assets/**"] let output_dir = context.temp_dir.join("export-mixed"); let mut dump_cmd = context.command(); - dump_cmd.args([ - "store", - "dump", - "--output", - output_dir.to_str().unwrap(), - &run_id, - ]); - let dump_output = dump_cmd.output().expect("store dump should execute"); + dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); + let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), - "store dump failed\nstdout:\n{}\nstderr:\n{}", + "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); @@ -260,14 +247,13 @@ include = ["assets/**"] } #[test] -fn store_dump_exports_completed_run_snapshot() { +fn dump_exports_completed_run_snapshot() { let context = test_context!(); let run = setup_completed_dry_run(&context); let output_dir = context.temp_dir.join("export"); let mut cmd = context.command(); cmd.args([ - "store", "dump", "--output", output_dir.to_str().unwrap(), @@ -298,7 +284,7 @@ fn store_dump_exports_completed_run_snapshot() { } #[test] -fn store_dump_rejects_non_empty_output_dir() { +fn dump_rejects_non_empty_output_dir() { let context = test_context!(); let run = setup_completed_dry_run(&context); let output_dir = context.temp_dir.join("nonempty"); @@ -307,7 +293,6 @@ fn store_dump_rejects_non_empty_output_dir() { let mut cmd = context.command(); cmd.args([ - "store", "dump", "--output", output_dir.to_str().unwrap(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index ee823c0f5..a8fbc255d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -25,7 +25,7 @@ fn help() { validate Validate a workflow graph Render a workflow graph as SVG artifact Inspect and copy run artifacts (screenshots, reports, traces) - store Export store-backed run state for debugging + dump Export a run's durable state to a directory rm Remove one or more workflow runs inspect Show detailed information about a workflow run archive Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 8cae23120..880ef3d99 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -9,6 +9,7 @@ mod diff; mod discord; mod docs; mod doctor; +mod dump; mod exec; mod fabro; mod fork; @@ -53,8 +54,6 @@ mod server_start; mod server_status; mod server_stop; mod start; -mod store; -mod store_dump; pub(crate) mod support; mod system; mod system_df; diff --git a/lib/crates/fabro-cli/tests/it/cmd/store.rs b/lib/crates/fabro-cli/tests/it/cmd/store.rs deleted file mode 100644 index 082ce6ba3..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/store.rs +++ /dev/null @@ -1,29 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["store", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Export store-backed run state for debugging - - Usage: fabro store [OPTIONS] - - Commands: - dump Export a run's durable state to a directory - help Print this message or the help of the given subcommand(s) - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help - ----- stderr ----- - "); -} diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 965558ff7..3d0dd66b5 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -134,16 +134,12 @@ fn help_smoke_covers_high_cost_commands() { ----- stdout ----- Inspect effective settings - Usage: fabro settings [OPTIONS] [WORKFLOW] - - Arguments: - [WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay + Usage: fabro settings [OPTIONS] Options: --json Output as JSON [env: FABRO_JSON=] --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --local Show only locally resolved settings and skip the server call --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs b/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs index ee9ba5be8..d0fbabe4f 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, read_conclusion, run_id_for, sandbox_tests, - store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for, + sandbox_tests, timeout_for, }; sandbox_tests!(command_agent_mixed, keys = ["ANTHROPIC_API_KEY"]); @@ -48,7 +48,7 @@ fn scenario_command_agent_mixed(sandbox: &str) { "verify should be completed" ); - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log")) .expect("verify stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs b/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs index 5a46d92e4..7c1d7eae7 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, read_conclusion, run_id_for, sandbox_tests, - store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for, + sandbox_tests, timeout_for, }; sandbox_tests!(command_pipeline); @@ -47,7 +47,7 @@ fn scenario_command_pipeline(sandbox: &str) { "step2 should be completed" ); - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout1 = std::fs::read_to_string(export_dir.join("stages/step1@1/stdout.log")) .expect("step1 stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs index 4b03f1155..a2a1feff4 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, has_event, read_conclusion, read_run_spec, run_id_for, - sandbox_tests, store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, has_event, read_conclusion, read_run_spec, + run_id_for, sandbox_tests, timeout_for, }; sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]); @@ -73,7 +73,7 @@ fn scenario_full_stack(sandbox: &str) { } // Verify node stdout should contain PASS - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log")) .expect("verify stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 5cf1005a4..b7911837a 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -59,17 +59,16 @@ pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool { .any(|event| event.event.event_name() == event_name) } -pub(super) fn store_dump_export(context: &TestContext, run_id: &str) -> PathBuf { - let output_dir = context.temp_dir.join(format!("store-dump-{run_id}")); +pub(super) fn dump_export(context: &TestContext, run_id: &str) -> PathBuf { + let output_dir = context.temp_dir.join(format!("dump-{run_id}")); context .command() .args([ - "store", "dump", "--output", output_dir .to_str() - .expect("store dump output path should be valid UTF-8"), + .expect("dump output path should be valid UTF-8"), run_id, ]) .assert() diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 61cbace50..582426a00 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -490,26 +490,16 @@ impl Client { } } - pub async fn retrieve_resolved_server_settings(&self) -> Result { - let url = format!("{}/api/v1/settings?view=resolved", self.base_url()); + pub async fn retrieve_resolved_server_settings(&self) -> Result { + let url = format!("{}/api/v1/settings", self.base_url()); let response = self .send_http(|http_client| async move { http_client.get(&url).send().await }) .await?; - let marker = response - .headers() - .get("x-fabro-settings-view") - .and_then(|value| value.to_str().ok()); - if marker != Some("resolved") { - bail!( - "server does not support resolved settings view; upgrade the server or use --local" - ); - } - response - .json::() + .json::() .await - .context("server returned invalid JSON for the resolved settings view") + .context("server returned invalid JSON for server settings") } pub async fn create_run_from_manifest(&self, manifest: types::RunManifest) -> Result { diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index c0a220b9f..a4ec98f7a 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -37,3 +37,4 @@ ulid.workspace = true [dev-dependencies] toml.workspace = true fabro-types = { path = "../fabro-types", features = ["test-support"] } +temp-env = "0.3" diff --git a/lib/crates/fabro-config/src/context.rs b/lib/crates/fabro-config/src/context.rs new file mode 100644 index 000000000..5cddd9e6a --- /dev/null +++ b/lib/crates/fabro-config/src/context.rs @@ -0,0 +1,56 @@ +use fabro_types::settings::{CliNamespace, FeaturesNamespace, ServerNamespace, SettingsLayer}; +use serde::{Deserialize, Serialize}; + +use crate::resolve::Resolver; +use crate::user::load_settings_config; +use crate::{Error, Result}; + +#[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 resolver = Resolver::from_layer(layer); + let mut errors = Vec::new(); + let server = resolver.server_into(&mut errors); + let features = resolver.features_into(&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 resolver = Resolver::from_layer(layer); + let mut errors = Vec::new(); + let cli = resolver.cli_into(&mut errors); + let features = resolver.features_into(&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) + } +} diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 685bf9bae..9a42750b8 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -5,7 +5,7 @@ //! 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 +//! `~/.fabro/settings.toml` plus explicit process-local overrides. Their //! stanzas in `.fabro/project.toml` and `workflow.toml` remain schema-valid but //! inert. @@ -16,13 +16,6 @@ use fabro_types::settings::server::ServerLayer; use crate::merge::combine_files; use crate::{Error, Result, apply_builtin_defaults}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum EffectiveSettingsMode { - LocalOnly, - RemoteServer, - LocalDaemon, -} - #[derive(Clone, Debug, Default)] pub struct EffectiveSettingsLayers { pub args: SettingsLayer, @@ -50,10 +43,28 @@ impl EffectiveSettingsLayers { /// 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>, - mode: EffectiveSettingsMode, ) -> Result { let EffectiveSettingsLayers { args, @@ -61,47 +72,28 @@ pub fn materialize_settings_layer( mut project, user, } = layers; + let server_settings = server_settings.ok_or(Error::MissingServerSettings)?; - let settings = match mode { - EffectiveSettingsMode::LocalOnly => { - combine_files(combine_files(combine_files(user, project), workflow), args) - } - EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => { - 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); + // 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); - let server_defaults = server_settings.clone(); + // 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 = - combine_files(combine_files(combine_files(user, project), workflow), args); - - let mut settings = match mode { - EffectiveSettingsMode::RemoteServer => { - apply_server_defaults(combined, &server_defaults) - } - EffectiveSettingsMode::LocalDaemon => { - apply_local_daemon_overrides(combined, &server_defaults) - } - EffectiveSettingsMode::LocalOnly => unreachable!(), - }; - // Storage root always comes from the server's local - // ~/.fabro/settings.toml, never from the client. - if let Some(server_root) = server_settings - .server - .as_ref() - .and_then(|s| s.storage.as_ref()) - .cloned() - { - let server = settings.server.get_or_insert_with(ServerLayer::default); - server.storage = Some(server_root); - } - settings - } - }; + let combined = combine_files( + combine_files( + combine_files(combine_files(server_defaults, user), project), + workflow, + ), + args, + ); + let settings = enforce_server_authority(combined, server_settings); Ok(apply_builtin_defaults(settings)) } @@ -111,30 +103,14 @@ fn strip_owner_domains(file: &mut SettingsLayer) { file.server = None; } -/// Apply server-side defaults to a client-layered [`SettingsLayer`]. +/// Apply server-owned fields on top of a client-combined [`SettingsLayer`]. /// -/// Server-owned domains (`server`, `features`, and parts of `run`) flow from -/// the server's local `~/.fabro/settings.toml` when the corresponding client -/// value is absent. Run-shaped defaults (model, prepare, sandbox, checkpoint, -/// hooks, agent mcps, etc.) also flow from server to client so the persisted -/// run spec matches the server's local configuration. -fn apply_server_defaults(mut settings: SettingsLayer, server: &SettingsLayer) -> SettingsLayer { - // Server-owned domains: server-side always wins when client left blank. - // Use the v2 merge matrix with the server layer in lower precedence so - // that client-supplied values still dominate when present. - settings = combine_files(server.clone(), settings); - settings -} - -/// Apply server-side overrides in LocalDaemon mode. -/// -/// In LocalDaemon mode, a subset of server-owned fields unconditionally -/// override any client-side values. Client-controlled run-level fields are -/// left alone. -fn apply_local_daemon_overrides( - mut settings: SettingsLayer, - server: &SettingsLayer, -) -> 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 { @@ -173,7 +149,7 @@ mod tests { use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer}; use fabro_types::settings::{InterpString, SettingsLayer}; - use super::{EffectiveSettingsLayers, EffectiveSettingsMode, materialize_settings_layer}; + use super::{EffectiveSettingsLayers, materialize_settings_layer}; use crate::parse::parse_settings_layer; fn layer(source: &str) -> SettingsLayer { @@ -181,7 +157,7 @@ mod tests { } #[test] - fn local_only_merges_project_and_user_layers() { + fn materialize_settings_layer_merges_layers_and_applies_server_authority() { let settings = materialize_settings_layer( EffectiveSettingsLayers::new( SettingsLayer::default(), @@ -214,8 +190,17 @@ shared = "user" "#, ), ), - None, - EffectiveSettingsMode::LocalOnly, + Some(&layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro" + +[server.scheduler] +max_concurrent_runs = 7 +"#, + )), ) .unwrap(); @@ -229,7 +214,7 @@ shared = "user" .as_deref(), Some("project-model") ); - // Per R22, run.inputs replaces wholesale — the winning layer is the + // 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 @@ -239,13 +224,31 @@ shared = "user" .unwrap(); assert!(inputs.contains_key("project_only")); assert_eq!( - inputs.get("shared").and_then(|v| v.as_str()), + 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 @@ -271,7 +274,7 @@ shared = "user" } #[test] - fn local_only_merges_workflow_project_user() { + fn materialize_settings_layer_preserves_client_values_with_empty_server_layer() { let settings = materialize_settings_layer( EffectiveSettingsLayers::new( SettingsLayer::default(), @@ -303,16 +306,13 @@ provider = "openai" "#, ), ), - None, - EffectiveSettingsMode::LocalOnly, + 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()) - } + Some(RunGoalLayer::Inline(value)) => Some(value.as_source()), _ => None, } .as_deref(), @@ -341,83 +341,7 @@ provider = "openai" } #[test] - fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() { - let server_settings = SettingsLayer { - server: Some(ServerLayer { - storage: Some(ServerStorageLayer { - root: Some(InterpString::parse("/srv/fabro")), - }), - scheduler: Some(ServerSchedulerLayer { - max_concurrent_runs: Some(9), - }), - ..ServerLayer::default() - }), - ..SettingsLayer::default() - }; - - let project_with_server = layer( - r#" -_version = 1 - -[run] -goal = "project goal" - -[server.storage] -root = "/tmp/should-be-inert" -"#, - ); - - let settings = materialize_settings_layer( - EffectiveSettingsLayers::new( - SettingsLayer::default(), - SettingsLayer::default(), - project_with_server, - SettingsLayer::default(), - ), - Some(&server_settings), - EffectiveSettingsMode::RemoteServer, - ) - .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!( - match settings.run.as_ref().and_then(|run| run.goal.as_ref()) { - Some(RunGoalLayer::Inline(value)) => { - Some(value.as_source()) - } - _ => None, - } - .as_deref(), - Some("project goal") - ); - 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.sandbox.as_ref()) - .and_then(|sandbox| sandbox.provider.as_deref()), - Some("local") - ); - } - - #[test] - fn local_daemon_mode_only_applies_server_owned_overrides() { + fn materialize_settings_layer_applies_server_owned_overrides() { let server_settings = SettingsLayer { server: Some(ServerLayer { storage: Some(ServerStorageLayer { @@ -431,12 +355,9 @@ root = "/tmp/should-be-inert" ..SettingsLayer::default() }; - let settings = materialize_settings_layer( - EffectiveSettingsLayers::default(), - Some(&server_settings), - EffectiveSettingsMode::LocalDaemon, - ) - .unwrap(); + let settings = + materialize_settings_layer(EffectiveSettingsLayers::default(), Some(&server_settings)) + .unwrap(); assert_eq!( settings diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index a405fdac7..ed5772707 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,9 +2,12 @@ clippy::disallowed_methods, reason = "sync config loading utilities used at startup; not on a Tokio path" )] +//! Resolved settings entrypoints: [`ServerSettings`] for the running server and +//! [`UserSettings`] for the CLI/user perspective. extern crate self as fabro_config; +pub mod context; mod defaults; pub mod bind; @@ -24,9 +27,9 @@ pub mod user; use std::path::Path; +pub use context::{ServerSettings, UserSettings}; pub use defaults::{apply_builtin_defaults, defaults_layer}; pub use error::{Error, Result}; -use fabro_types::settings::{Settings, SettingsLayer}; pub use fabro_util::path::expand_tilde; pub use home::Home; pub use load::{ @@ -34,23 +37,14 @@ pub use load::{ }; pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ - ResolveError, dev_token_auth_enabled, resolve, 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, Resolver, 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, }; use serde::de::DeserializeOwned; pub use storage::{RunScratch, RuntimeDirectory, Storage}; -pub fn load_and_resolve( - layers: effective_settings::EffectiveSettingsLayers, - server_settings: Option<&SettingsLayer>, - mode: effective_settings::EffectiveSettingsMode, -) -> Result { - let layer = effective_settings::materialize_settings_layer(layers, server_settings, mode)?; - resolve(&layer).map_err(|errors| Error::resolve("failed to resolve settings", errors)) -} - /// Load a TOML config from an explicit path or `~/.fabro/{filename}`. /// /// Returns `T::default()` when no explicit path is given and the default file diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/crates/fabro-config/src/resolve/cli.rs index 4905359d1..2c7e09345 100644 --- a/lib/crates/fabro-config/src/resolve/cli.rs +++ b/lib/crates/fabro-config/src/resolve/cli.rs @@ -1,13 +1,13 @@ use fabro_types::settings::cli::{ CliAuthSettings, CliExecAgentSettings, CliExecLayer, CliExecModelSettings, CliExecSettings, - CliLayer, CliLoggingSettings, CliOutputSettings, CliSettings, CliTargetLayer, + CliLayer, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetLayer, CliTargetSettings, CliUpdatesSettings, }; use super::{ResolveError, require_interp}; -pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliSettings { - CliSettings { +pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliNamespace { + CliNamespace { target: resolve_target(layer.target.as_ref(), errors), auth: CliAuthSettings { strategy: layer.auth.as_ref().and_then(|auth| auth.strategy), diff --git a/lib/crates/fabro-config/src/resolve/features.rs b/lib/crates/fabro-config/src/resolve/features.rs index e6c8eeced..a9a2d1254 100644 --- a/lib/crates/fabro-config/src/resolve/features.rs +++ b/lib/crates/fabro-config/src/resolve/features.rs @@ -1,12 +1,12 @@ -use fabro_types::settings::features::{FeaturesLayer, FeaturesSettings}; +use fabro_types::settings::features::{FeaturesLayer, FeaturesNamespace}; use super::ResolveError; pub fn resolve_features( layer: &FeaturesLayer, _errors: &mut Vec, -) -> FeaturesSettings { - FeaturesSettings { +) -> FeaturesNamespace { + FeaturesNamespace { session_sandboxes: layer .session_sandboxes .expect("defaults.toml should provide features.session_sandboxes"), diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 9032464a0..f35282ed7 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -2,6 +2,7 @@ mod cli; mod error; mod features; mod project; +mod resolver; mod run; mod server; mod workflow; @@ -9,119 +10,60 @@ mod workflow; pub use cli::resolve_cli; pub use error::ResolveError; use fabro_types::settings::{ - CliSettings, FeaturesSettings, InterpString, ProjectSettings, RunSettings, ServerSettings, - Settings, SettingsLayer, WorkflowSettings, + CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, + SettingsLayer, WorkflowNamespace, }; pub use features::resolve_features; pub use project::resolve_project; +pub use resolver::Resolver; pub use run::resolve_run; -pub use server::{dev_token_auth_enabled, resolve_server, resolve_storage_root}; +pub use server::{dev_token_auth_enabled, resolve_server}; pub use workflow::resolve_workflow; -use crate::apply_builtin_defaults; - -pub fn resolve(file: &SettingsLayer) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let project_layer = file.project.clone().unwrap_or_default(); - let workflow_layer = file.workflow.clone().unwrap_or_default(); - let run_layer = file.run.clone().unwrap_or_default(); - let cli_layer = file.cli.clone().unwrap_or_default(); - let server_layer = file.server.clone().unwrap_or_default(); - let features_layer = file.features.clone().unwrap_or_default(); - - let settings = Settings { - project: resolve_project(&project_layer, &mut errors), - workflow: resolve_workflow(&workflow_layer, &mut errors), - run: resolve_run(&run_layer, &mut errors), - cli: resolve_cli(&cli_layer, &mut errors), - server: resolve_server(&server_layer, &mut errors), - features: resolve_features(&features_layer, &mut errors), - }; - - if errors.is_empty() { - Ok(settings) - } else { - Err(errors) - } +pub fn resolve_storage_root(file: &SettingsLayer) -> InterpString { + Resolver::from_layer(file).storage_root() } -pub fn resolve_cli_from_file(file: &SettingsLayer) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let cli_layer = file.cli.clone().unwrap_or_default(); - let cli = resolve_cli(&cli_layer, &mut errors); - if errors.is_empty() { - Ok(cli) - } else { - Err(errors) - } +pub fn resolve_cli_from_file(file: &SettingsLayer) -> Result> { + Resolver::from_layer(file).cli() } -pub fn resolve_server_from_file(file: &SettingsLayer) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let server_layer = file.server.clone().unwrap_or_default(); - let server = resolve_server(&server_layer, &mut errors); - if errors.is_empty() { - Ok(server) - } else { - Err(errors) - } +pub fn resolve_server_from_file( + file: &SettingsLayer, +) -> Result> { + Resolver::from_layer(file).server() } pub fn resolve_project_from_file( file: &SettingsLayer, -) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let project_layer = file.project.clone().unwrap_or_default(); - let project = resolve_project(&project_layer, &mut errors); - if errors.is_empty() { - Ok(project) - } else { - Err(errors) - } +) -> Result> { + Resolver::from_layer(file).project() } pub fn resolve_features_from_file( file: &SettingsLayer, -) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let features_layer = file.features.clone().unwrap_or_default(); - let features = resolve_features(&features_layer, &mut errors); - if errors.is_empty() { - Ok(features) - } else { - Err(errors) - } +) -> Result> { + Resolver::from_layer(file).features() } -pub fn resolve_run_from_file(file: &SettingsLayer) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let run_layer = file.run.clone().unwrap_or_default(); - let run = resolve_run(&run_layer, &mut errors); - if errors.is_empty() { - Ok(run) - } else { - Err(errors) - } +pub fn resolve_run_from_file(file: &SettingsLayer) -> Result> { + Resolver::from_layer(file).run() } pub fn resolve_workflow_from_file( file: &SettingsLayer, -) -> Result> { - let file = apply_builtin_defaults(file.clone()); - let mut errors = Vec::new(); - let workflow_layer = file.workflow.clone().unwrap_or_default(); - let workflow = resolve_workflow(&workflow_layer, &mut errors); - if errors.is_empty() { - Ok(workflow) - } else { - Err(errors) - } +) -> Result> { + Resolver::from_layer(file).workflow() +} + +/// 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( @@ -165,7 +107,7 @@ mod tests { use fabro_types::settings::run::{HookType, McpTransport, TlsMode}; - use super::resolve; + use super::resolve_run_from_file; use crate::parse_settings_layer; #[test] @@ -210,8 +152,8 @@ Authorization = "Bearer {{ env.HOOK_TOKEN }}" ) .expect("settings fixture should parse"); - let resolved = resolve(&settings).expect("settings should resolve"); - let mcps = &resolved.run.agent.mcps; + let resolved = resolve_run_from_file(&settings).expect("run settings should resolve"); + let mcps = &resolved.agent.mcps; assert_eq!( mcps.get("stdio").map(|mcp| &mcp.transport), @@ -246,7 +188,6 @@ Authorization = "Bearer {{ env.HOOK_TOKEN }}" ); let hook = resolved - .run .hooks .iter() .find(|hook| hook.name.as_deref() == Some("notify")) diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/crates/fabro-config/src/resolve/project.rs index dbed59297..1bd5ee0e7 100644 --- a/lib/crates/fabro-config/src/resolve/project.rs +++ b/lib/crates/fabro-config/src/resolve/project.rs @@ -1,9 +1,9 @@ -use fabro_types::settings::project::{ProjectLayer, ProjectSettings}; +use fabro_types::settings::project::{ProjectLayer, ProjectNamespace}; use super::ResolveError; -pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> ProjectSettings { - ProjectSettings { +pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> ProjectNamespace { + ProjectNamespace { name: layer.name.clone(), description: layer.description.clone(), directory: layer diff --git a/lib/crates/fabro-config/src/resolve/resolver.rs b/lib/crates/fabro-config/src/resolve/resolver.rs new file mode 100644 index 000000000..893aa41ac --- /dev/null +++ b/lib/crates/fabro-config/src/resolve/resolver.rs @@ -0,0 +1,122 @@ +//! Cache builtin defaults across multiple per-namespace resolutions. +//! +//! [`resolve_storage_root`] and the per-namespace `resolve_*_from_file` +//! helpers each call [`apply_builtin_defaults`], which clones both the input +//! layer and the embedded defaults layer before merging them. Callers that +//! need more than one namespace would otherwise pay that cost N times. +//! +//! [`Resolver`] applies defaults once on construction, then exposes per- +//! namespace methods that work against the materialized layer. It is the +//! shared backend for the standalone `resolve_*_from_file` helpers and the +//! preferred entrypoint when more than one namespace is needed. + +use fabro_types::settings::{ + CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, + SettingsLayer, WorkflowNamespace, +}; + +use super::{ + ResolveError, default_interp, resolve_cli, resolve_features, resolve_project, resolve_run, + resolve_server, resolve_workflow, +}; +use crate::apply_builtin_defaults; +use crate::user::default_storage_dir; + +pub struct Resolver { + layer: SettingsLayer, +} + +impl Resolver { + #[must_use] + pub fn from_layer(layer: &SettingsLayer) -> Self { + Self { + layer: apply_builtin_defaults(layer.clone()), + } + } + + pub fn cli(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.cli_into(&mut errors); + finish(value, errors) + } + + pub fn server(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.server_into(&mut errors); + finish(value, errors) + } + + pub fn project(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.project_into(&mut errors); + finish(value, errors) + } + + pub fn features(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.features_into(&mut errors); + finish(value, errors) + } + + pub fn run(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.run_into(&mut errors); + finish(value, errors) + } + + pub fn workflow(&self) -> Result> { + let mut errors = Vec::new(); + let value = self.workflow_into(&mut errors); + finish(value, errors) + } + + /// Resolved storage root, defaulting to [`default_storage_dir`] when the + /// input layer doesn't pin one. + #[must_use] + pub fn storage_root(&self) -> InterpString { + self.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 cli_into(&self, errors: &mut Vec) -> CliNamespace { + let layer = self.layer.cli.clone().unwrap_or_default(); + resolve_cli(&layer, errors) + } + + pub fn server_into(&self, errors: &mut Vec) -> ServerNamespace { + let layer = self.layer.server.clone().unwrap_or_default(); + resolve_server(&layer, errors) + } + + pub fn project_into(&self, errors: &mut Vec) -> ProjectNamespace { + let layer = self.layer.project.clone().unwrap_or_default(); + resolve_project(&layer, errors) + } + + pub fn features_into(&self, errors: &mut Vec) -> FeaturesNamespace { + let layer = self.layer.features.clone().unwrap_or_default(); + resolve_features(&layer, errors) + } + + pub fn run_into(&self, errors: &mut Vec) -> RunNamespace { + let layer = self.layer.run.clone().unwrap_or_default(); + resolve_run(&layer, errors) + } + + pub fn workflow_into(&self, errors: &mut Vec) -> WorkflowNamespace { + let layer = self.layer.workflow.clone().unwrap_or_default(); + resolve_workflow(&layer, errors) + } +} + +fn finish(value: T, errors: Vec) -> Result> { + if errors.is_empty() { + Ok(value) + } else { + Err(errors) + } +} diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 353715645..f2505fd23 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -8,15 +8,15 @@ use fabro_types::settings::run::{ NotificationRouteLayer, NotificationRouteSettings, PullRequestSettings, RunAgentLayer, RunAgentSettings, RunArtifactsLayer, RunCheckpointLayer, RunCheckpointSettings, RunExecutionLayer, RunExecutionSettings, RunGitLayer, RunGitSettings, RunGoal, RunGoalLayer, - RunInterviewsSettings, RunLayer, RunModelLayer, RunModelSettings, RunPrepareLayer, - RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings, RunScmLayer, - RunScmSettings, RunSettings, ScmGitHubSettings, StringOrSplice, TlsMode, + RunInterviewsSettings, RunLayer, RunModelLayer, RunModelSettings, RunNamespace, + RunPrepareLayer, RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings, + RunScmLayer, RunScmSettings, ScmGitHubSettings, StringOrSplice, TlsMode, }; use super::ResolveError; -pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunSettings { - RunSettings { +pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNamespace { + RunNamespace { goal: resolve_goal(layer.goal.as_ref()), working_dir: layer.working_dir.clone(), metadata: layer.metadata.clone(), diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 8f8307b25..dc73c8da3 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -6,7 +6,7 @@ use fabro_types::settings::server::{ ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, - ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, + ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, WebhookStrategy, @@ -17,15 +17,6 @@ use fabro_util::Home; use super::{ResolveError, default_interp, parse_socket_addr, require_interp}; use crate::user::default_storage_dir; -pub fn resolve_storage_root(file: &SettingsLayer) -> InterpString { - let file = crate::apply_builtin_defaults(file.clone()); - file.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 dev_token_auth_enabled(layer: &SettingsLayer) -> bool { layer .server @@ -35,7 +26,7 @@ pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { .is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken)) } -pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerSettings { +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); let web = resolve_web(layer.api.as_ref(), layer.web.as_ref()); @@ -46,7 +37,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors); validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors); - ServerSettings { + ServerNamespace { listen, api: ServerApiSettings { url: layer.api.as_ref().and_then(|api| api.url.clone()), diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/crates/fabro-config/src/resolve/workflow.rs index dee80d1ba..5bb5bc139 100644 --- a/lib/crates/fabro-config/src/resolve/workflow.rs +++ b/lib/crates/fabro-config/src/resolve/workflow.rs @@ -1,12 +1,12 @@ -use fabro_types::settings::workflow::{WorkflowLayer, WorkflowSettings}; +use fabro_types::settings::workflow::{WorkflowLayer, WorkflowNamespace}; use super::ResolveError; pub fn resolve_workflow( layer: &WorkflowLayer, _errors: &mut Vec, -) -> WorkflowSettings { - WorkflowSettings { +) -> WorkflowNamespace { + WorkflowNamespace { name: layer.name.clone(), description: layer.description.clone(), graph: layer diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 951ae75aa..298cf2f48 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -63,6 +63,24 @@ fn load_v2_layer_from_path(path: &Path) -> Result { load_settings_path(path) } +/// Override the resolved storage root in a settings layer with a runtime path. +pub fn apply_storage_dir_override( + mut layer: SettingsLayer, + storage_dir: Option<&Path>, +) -> SettingsLayer { + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; + if let Some(dir) = storage_dir { + let server = layer.server.get_or_insert_with(ServerLayer::default); + let storage = server + .storage + .get_or_insert_with(ServerStorageLayer::default); + storage.root = Some(InterpString::parse(&dir.display().to_string())); + } + + layer +} + #[cfg(test)] mod tests { use super::{ diff --git a/lib/crates/fabro-config/tests/defaults.rs b/lib/crates/fabro-config/tests/defaults.rs index ff67dc914..dc5aa78c3 100644 --- a/lib/crates/fabro-config/tests/defaults.rs +++ b/lib/crates/fabro-config/tests/defaults.rs @@ -1,4 +1,7 @@ -use fabro_config::{apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve}; +use fabro_config::{ + apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve_run_from_file, + resolve_server_from_file, resolve_workflow_from_file, +}; use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::OutputFormat; use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode}; @@ -91,7 +94,8 @@ fn apply_builtin_defaults_materializes_expected_layer() { #[test] fn resolve_empty_settings_requires_explicit_server_auth_methods() { - let errors = resolve(&SettingsLayer::default()).expect_err("empty settings should fail"); + let errors = resolve_server_from_file(&SettingsLayer::default()) + .expect_err("empty server settings should fail"); assert!(errors.iter().any(|error| { matches!( @@ -115,9 +119,10 @@ mode = "dry_run" "#, ); - let settings = resolve(&layer).expect("settings should resolve"); + let workflow = resolve_workflow_from_file(&layer).expect("workflow settings should resolve"); + let run = resolve_run_from_file(&layer).expect("run settings should resolve"); - assert_eq!(settings.run.execution.mode, RunMode::DryRun); - assert_eq!(settings.run.execution.approval, ApprovalMode::Prompt); - assert_eq!(settings.workflow.graph, "workflow.fabro"); + assert_eq!(run.execution.mode, RunMode::DryRun); + assert_eq!(run.execution.approval, ApprovalMode::Prompt); + assert_eq!(workflow.graph, "workflow.fabro"); } diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs index e23f0fce8..01c05b60a 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -1,7 +1,13 @@ +#![expect( + clippy::disallowed_methods, + reason = "sync test fixture setup; not on a Tokio path" +)] + use fabro_config::{parse_settings_layer, resolve_cli_from_file}; 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; #[test] fn resolves_cli_defaults_from_empty_settings() { @@ -17,6 +23,74 @@ fn resolves_cli_defaults_from_empty_settings() { assert!(cli.logging.level.is_none()); } +#[test] +fn user_settings_from_layer_matches_namespace_resolvers() { + let settings: SettingsLayer = parse_settings_layer( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" + +[features] +session_sandboxes = true +"#, + ) + .expect("fixture should parse"); + + let user_settings = + fabro_config::UserSettings::from_layer(&settings).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") + ); +} + +#[test] +fn user_settings_resolve_reads_default_settings_from_fabro_home() { + let home = tempfile::tempdir().unwrap(); + std::fs::write( + home.path().join("settings.toml"), + r#" +_version = 1 + +[cli.output] +verbosity = "verbose" + +[features] +session_sandboxes = true +"#, + ) + .unwrap(); + + with_var("FABRO_HOME", Some(home.path()), || { + let user_settings = + fabro_config::UserSettings::resolve().expect("user settings should resolve"); + assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Verbose); + assert!(user_settings.features.session_sandboxes); + }); +} + +#[test] +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"); + assert_eq!(user_settings.cli.output.format, OutputFormat::Text); + assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Normal); + assert!(!user_settings.features.session_sandboxes); + }); +} + #[test] fn resolves_cli_target_exec_and_output_settings() { let settings: SettingsLayer = parse_settings_layer( diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index b6176c6ad..d4620053e 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -1,4 +1,3 @@ -use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; use fabro_config::parse_settings_layer; use fabro_types::settings::{InterpString, SettingsLayer}; @@ -8,8 +7,8 @@ fn parse(source: &str) -> SettingsLayer { #[test] fn resolves_root_settings_require_explicit_server_auth_methods() { - let errors = - fabro_config::resolve(&SettingsLayer::default()).expect_err("empty settings should fail"); + let errors = fabro_config::resolve_server_from_file(&SettingsLayer::default()) + .expect_err("empty server settings should fail"); assert!(errors.iter().any(|error| { matches!( @@ -40,12 +39,20 @@ provider = "not-a-provider" "#, ); - let errors = fabro_config::resolve(&settings).expect_err("invalid shape should fail"); - let rendered = errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n"); + let mut rendered = Vec::new(); + rendered.extend( + fabro_config::resolve_server_from_file(&settings) + .expect_err("invalid server settings should fail") + .into_iter() + .map(|error| error.to_string()), + ); + rendered.extend( + fabro_config::resolve_run_from_file(&settings) + .expect_err("invalid run settings should fail") + .into_iter() + .map(|error| error.to_string()), + ); + let rendered = rendered.join("\n"); assert!(rendered.contains("server.listen.address")); assert!(rendered.contains("server.auth.github.allowed_usernames")); @@ -53,66 +60,45 @@ provider = "not-a-provider" } #[test] -fn load_and_resolve_merges_layers_before_resolution() { - let settings = fabro_config::load_and_resolve( - EffectiveSettingsLayers::new( - SettingsLayer::default(), - parse( - r#" -_version = 1 - -[workflow] -graph = "graphs/workflow.dot" -"#, - ), - parse( - r#" +fn namespace_resolvers_cover_root_level_settings_shape() { + let settings = parse( + r#" _version = 1 [project] directory = ".fabro" -"#, - ), - parse( - r#" -_version = 1 + +[workflow] +graph = "graphs/workflow.dot" [server.storage] root = "/srv/fabro" [server.auth] methods = ["dev-token"] - [run.model] provider = "openai" name = "gpt-5" "#, - ), - ), - None, - EffectiveSettingsMode::LocalOnly, - ) - .expect("layers should load and resolve"); + ); - assert_eq!(settings.project.directory, ".fabro"); - assert_eq!(settings.workflow.graph, "graphs/workflow.dot"); - assert_eq!(settings.server.storage.root.as_source(), "/srv/fabro"); + let project = fabro_config::resolve_project_from_file(&settings) + .expect("project settings should resolve"); + let workflow = fabro_config::resolve_workflow_from_file(&settings) + .expect("workflow settings should resolve"); + let server = + fabro_config::resolve_server_from_file(&settings).expect("server settings should resolve"); + let run = fabro_config::resolve_run_from_file(&settings).expect("run settings should resolve"); + + assert_eq!(project.directory, ".fabro"); + assert_eq!(workflow.graph, "graphs/workflow.dot"); + assert_eq!(server.storage.root.as_source(), "/srv/fabro"); assert_eq!( - settings - .run - .model - .provider - .as_ref() - .map(InterpString::as_source), + 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), + run.model.name.as_ref().map(InterpString::as_source), Some("gpt-5".to_string()) ); } diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index 1da6f3ed1..445fb834f 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::disallowed_methods, + reason = "sync test fixture setup; not on a Tokio path" +)] + use fabro_config::parse_settings_layer; use fabro_config::user::default_storage_dir; use fabro_types::settings::server::{ @@ -5,6 +10,7 @@ use fabro_types::settings::server::{ }; use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_util::Home; +use temp_env::with_var; fn parse(source: &str) -> SettingsLayer { let mut layer = parse_settings_layer(source).expect("fixture should parse"); @@ -69,6 +75,64 @@ fn resolves_server_defaults_from_empty_settings() { assert!(!settings.slatedb.disk_cache); } +#[test] +fn server_settings_from_layer_matches_namespace_resolvers() { + let settings = parse( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.storage] +root = "/srv/fabro" + +[features] +session_sandboxes = true +"#, + ); + + let context = + fabro_config::ServerSettings::from_layer(&settings).expect("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") + ); +} + +#[test] +fn server_settings_resolve_reads_default_settings_from_fabro_home() { + let home = tempfile::tempdir().unwrap(); + std::fs::write( + home.path().join("settings.toml"), + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.storage] +root = "/srv/from-home" + +[features] +session_sandboxes = true +"#, + ) + .unwrap(); + + with_var("FABRO_HOME", Some(home.path()), || { + let settings = fabro_config::ServerSettings::resolve().expect("settings should resolve"); + assert_eq!(settings.server.storage.root.as_source(), "/srv/from-home"); + assert!(settings.features.session_sandboxes); + }); +} + #[test] fn parsing_rejects_inbound_listener_tls_configuration() { let err = fabro_config::parse_settings_layer( diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index c74a8da7e..a7851a872 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -67,6 +67,10 @@ rand.workspace = true bytes = "1" tempfile = "3" object_store.workspace = true +# reqwest 0.12 is a direct dep only so `NoProxyReqwestConnector` can build a +# `reqwest::Client` of the version object_store accepts. Rest of the crate +# talks to HTTP via fabro-http (reqwest 0.13). +object_store_reqwest = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } mime_guess.workspace = true regex.workspace = true semver.workspace = true diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index 36345cdc2..4b052a40d 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -1013,6 +1013,7 @@ fn pkce_challenge(verifier: &str) -> String { fn login_allowed(state: &AppState, login: &str) -> bool { state .server_settings() + .server .auth .github .allowed_usernames @@ -1372,7 +1373,6 @@ mod tests { let state = server::create_test_app_state_with_session_key( settings, Some("cli-flow-test-key-material-0123456789"), - false, ); let app = axum::Router::new() .nest("/auth", web_routes()) diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/crates/fabro-server/src/auth/translate.rs index 5c05edb37..2d124f2c5 100644 --- a/lib/crates/fabro-server/src/auth/translate.rs +++ b/lib/crates/fabro-server/src/auth/translate.rs @@ -225,7 +225,6 @@ mod tests { server::create_test_app_state_with_session_key( SettingsLayer::default(), Some(SESSION_SECRET), - false, ) } diff --git a/lib/crates/fabro-server/src/canonical_origin.rs b/lib/crates/fabro-server/src/canonical_origin.rs index fdbb5fec9..203a86abe 100644 --- a/lib/crates/fabro-server/src/canonical_origin.rs +++ b/lib/crates/fabro-server/src/canonical_origin.rs @@ -1,10 +1,10 @@ -use fabro_types::settings::ServerSettings as ResolvedServerSettings; +use fabro_types::settings::ServerNamespace; use url::Url; use crate::server::EnvLookup; pub(crate) fn resolve_canonical_origin( - resolved: &ResolvedServerSettings, + resolved: &ServerNamespace, env_lookup: &EnvLookup, ) -> Result { let value = resolved diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 7a5b57b0a..3709bd62d 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -24,7 +24,6 @@ use crate::error::ApiError; use crate::jwt_auth::AuthenticatedService; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::server::{AppState, PaginationParams}; -use crate::settings_view; fn paginated_response( items: Vec, @@ -601,22 +600,8 @@ pub(crate) async fn list_query_history( pub(crate) async fn get_server_settings( _auth: AuthenticatedService, State(_state): State>, - Query(query): Query, ) -> Response { - match query.view { - settings_view::SettingsApiView::Layer => { - (StatusCode::OK, Json(settings::server_settings())).into_response() - } - settings_view::SettingsApiView::Resolved => { - let mut response = - (StatusCode::OK, Json(settings::resolved_server_settings())).into_response(); - response.headers_mut().insert( - settings_view::RESOLVED_VIEW_HEADER_NAME, - axum::http::HeaderValue::from_static(settings_view::RESOLVED_VIEW_HEADER_VALUE), - ); - response - } - } + (StatusCode::OK, Json(settings::server_settings())).into_response() } // ── System ──────────────────────────────────────────────────────────── @@ -1569,209 +1554,58 @@ mod insights { } mod settings { - pub(super) fn server_settings() -> serde_json::Value { - // v2 SettingsLayer shape — matches what /api/v1/settings returns in - // production, so the demo renders identically. - serde_json::json!({ - "_version": 1, - "server": { - "storage": { - "root": "/home/fabro/.fabro" - }, - "scheduler": { - "max_concurrent_runs": 10 - }, - "api": { - "url": "https://api.fabro.example.com" - }, - "web": { - "enabled": true, - "url": "https://fabro.example.com" - }, - "auth": { - "api": { - "jwt": { "enabled": true } - }, - "web": { - "allowed_usernames": ["brynary", "alice"], - "providers": { - "github": { - "enabled": true, - "client_id": "Iv1.abc123" - } - } - } - }, - "integrations": { - "github": { - "app_id": "12345", - "client_id": "Iv1.abc123", - "slug": "fabro-dev" - } - } - }, - "run": { - "model": { - "provider": "anthropic", - "name": "claude-sonnet" - }, - "sandbox": { - "provider": "daytona", - "daytona": { - "auto_stop_interval": 60, - "network": "block" - } - } - }, - "features": { - "session_sandboxes": false, - "retros": false - } - }) - } + use std::sync::OnceLock; - pub(super) fn resolved_server_settings() -> serde_json::Value { - serde_json::json!({ - "project": { - "directory": "." - }, - "workflow": { - "graph": "workflow.fabro" - }, - "run": { - "model": { - "provider": "anthropic", - "name": "claude-sonnet", - "fallbacks": [] - }, - "execution": { - "mode": "normal", - "approval": "prompt", - "retros": true - }, - "sandbox": { - "provider": "daytona", - "preserve": false, - "devcontainer": false, - "env": {}, - "local": { - "worktree_mode": "clean" - }, - "daytona": { - "auto_stop_interval": 60, - "labels": {}, - "network": "block", - "skip_clone": false - } - }, - "notifications": {}, - "interviews": {}, - "agent": { - "mcps": {} - }, - "hooks": [], - "scm": {}, - "artifacts": { - "include": [] - }, - "inputs": {}, - "metadata": {}, - "git": {}, - "prepare": { - "commands": [], - "timeout_ms": 300000 - }, - "checkpoint": { - "exclude_globs": [] - } - }, - "cli": { - "auth": {}, - "exec": { - "prevent_idle_sleep": false, - "model": {}, - "agent": { - "mcps": {} - } - }, - "output": { - "format": "text", - "verbosity": "normal" - }, - "updates": { - "check": true - }, - "logging": {} - }, - "server": { - "api": { - "url": "https://api.fabro.example.com" - }, - "web": { - "enabled": true, - "url": "https://fabro.example.com" - }, - "auth": { - "api": { - "jwt": { - "enabled": true - } - }, - "web": { - "allowed_usernames": ["brynary", "alice"], - "providers": { - "github": { - "enabled": true, - "client_id": "Iv1.abc123" - } - } - } - }, - "storage": { - "root": "/home/fabro/.fabro" - }, - "artifacts": { - "prefix": "", - "store": { - "type": "local", - "root": "" - } - }, - "slatedb": { - "prefix": "", - "store": { - "type": "local", - "root": "" - }, - "flush_interval": "0s" - }, - "scheduler": { - "max_concurrent_runs": 10 - }, - "logging": {}, - "integrations": { - "github": { - "enabled": false, - "strategy": "token", - "app_id": "12345", - "client_id": "Iv1.abc123", - "slug": "fabro-dev", - "permissions": {} - }, - "slack": { - "enabled": false - }, - "discord": { - "enabled": false - }, - "teams": { - "enabled": false - } - } - }, - "features": { - "session_sandboxes": false - } - }) + pub(super) fn server_settings() -> serde_json::Value { + static CACHED: OnceLock = OnceLock::new(); + CACHED + .get_or_init(|| { + let settings = fabro_config::parse_settings_layer( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://api.fabro.example.com" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["brynary", "alice"] + +[server.storage] +root = "/home/fabro/.fabro" + +[server.scheduler] +max_concurrent_runs = 10 + +[server.integrations.github] +enabled = true +strategy = "app" +app_id = "12345" +client_id = "Iv1.abc123" +slug = "fabro-dev" + +[features] +session_sandboxes = false +"#, + ) + .expect("demo settings fixture should parse"); + + serde_json::to_value( + fabro_config::ServerSettings::from_layer(&settings) + .expect("demo settings fixture should resolve"), + ) + .expect("demo settings should serialize") + }) + .clone() } } diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 4b1d6e82d..7ea025eee 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -177,8 +177,8 @@ async fn probe_llm_provider(client: &LlmClient, provider: Provider) -> Result<() async fn check_github_app(state: &AppState) -> CheckResult { let settings = state.server_settings(); - if settings.integrations.github.strategy == GithubIntegrationStrategy::Token { - let token = match state.github_credentials(&settings.integrations.github) { + if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token { + let token = match state.github_credentials(&settings.server.integrations.github) { Ok(Some(fabro_github::GitHubCredentials::Token(token))) => token, Ok(Some(_)) => unreachable!("token strategy should not return app credentials"), Ok(None) => { @@ -263,19 +263,21 @@ async fn check_github_app(state: &AppState) -> CheckResult { } let app_id = settings + .server .integrations .github .app_id .as_ref() .map(InterpString::as_source); let slug = settings + .server .integrations .github .slug .as_ref() .map(InterpString::as_source); let private_key_raw = state.server_secret("GITHUB_APP_PRIVATE_KEY"); - let client_id = settings.integrations.github.client_id.is_some(); + let client_id = settings.server.integrations.github.client_id.is_some(); let client_secret = state.server_secret("GITHUB_APP_CLIENT_SECRET").is_some(); let webhook_secret = state.server_secret("GITHUB_APP_WEBHOOK_SECRET").is_some(); @@ -504,7 +506,7 @@ fn check_crypto(state: &AppState) -> CheckResult { let mut details = Vec::new(); let mut errors = Vec::new(); - if resolved_server_settings.web.enabled { + if resolved_server_settings.server.web.enabled { match state.server_secret("SESSION_SECRET") { Some(secret) => { if let Err(err) = validate_session_secret(&secret) { @@ -515,7 +517,7 @@ fn check_crypto(state: &AppState) -> CheckResult { } } - let methods = &resolved_server_settings.auth.methods; + let methods = &resolved_server_settings.server.auth.methods; if methods.contains(&ServerAuthMethod::DevToken) { match state.server_secret("FABRO_DEV_TOKEN") { Some(token) if validate_dev_token_format(&token) => {} @@ -525,6 +527,7 @@ fn check_crypto(state: &AppState) -> CheckResult { } if methods.contains(&ServerAuthMethod::Github) { if resolved_server_settings + .server .integrations .github .client_id diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index f0e81d4aa..301d7a943 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -13,9 +13,9 @@ use axum::{Json, Router, middleware}; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; use fabro_auth::{AuthCredential, AuthDetails, credential_id_for}; +use fabro_config::Storage; use fabro_config::bind::{Bind, BindRequest}; use fabro_config::envfile::EnvFileUpdate; -use fabro_config::{Storage, resolve_server_from_file}; use fabro_install::{ InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, VaultSecretWrite, merge_server_settings, persist_install_outputs_direct, @@ -107,7 +107,23 @@ impl InstallAppState { } #[must_use] + #[expect( + unsafe_code, + reason = "test-only: set FABRO_TEST_IN_MEMORY_STORE to a constant so install tests \ + don't hang on real S3; parallel tests race on the same value" + )] + #[expect( + clippy::disallowed_methods, + reason = "test-only: forces the in-memory object store for install tests so they \ + don't contact real S3" + )] pub fn for_test_with_paths(token: &str, storage_dir: &Path, config_path: &Path) -> Self { + // Install-flow tests verify persistence and redaction, not S3 + // reachability. Force the in-memory object store shortcut so + // /install/finish can't hang on an unreachable bucket. + unsafe { + std::env::set_var("FABRO_TEST_IN_MEMORY_STORE", "1"); + } Self { install_token: Arc::from(token), pending_install: Arc::new(Mutex::new(PendingInstall::default())), @@ -1885,17 +1901,9 @@ async fn write_artifact_store_metadata( .get_or_insert_with(ServerStorageLayer::default); storage.root = Some(InterpString::parse(&storage_dir.display().to_string())); - let resolved = resolve_server_from_file(&settings).map_err(|errors| { - anyhow::anyhow!( - "failed to resolve server settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) - })?; - let (object_store, prefix) = serve::build_artifact_object_store(&resolved)?; + let resolved = + fabro_config::ServerSettings::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?; Ok(()) diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 8d3ca7f03..b332cb797 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -2,7 +2,7 @@ use anyhow::{Result, anyhow}; use axum::extract::FromRequestParts; use axum::http::header; use axum::http::request::Parts; -use fabro_types::settings::{ServerAuthMethod, ServerSettings as ResolvedServerSettings}; +use fabro_types::settings::{ServerAuthMethod, ServerNamespace}; use fabro_types::{IdpIdentity, RunAuthMethod}; use fabro_util::dev_token::validate_dev_token_format; use hmac::{Hmac, Mac}; @@ -51,14 +51,11 @@ pub enum AuthMode { Disabled, } -pub fn resolve_auth_mode(settings: &ResolvedServerSettings) -> Result { +pub fn resolve_auth_mode(settings: &ServerNamespace) -> Result { resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok()) } -pub fn resolve_auth_mode_with_lookup( - settings: &ResolvedServerSettings, - lookup: F, -) -> Result +pub fn resolve_auth_mode_with_lookup(settings: &ServerNamespace, lookup: F) -> Result where F: Fn(&str) -> Option, { @@ -124,7 +121,7 @@ where })) } -fn resolve_jwt_issuer(settings: &ResolvedServerSettings, lookup: &F) -> String +fn resolve_jwt_issuer(settings: &ServerNamespace, lookup: &F) -> String where F: Fn(&str) -> Option, { @@ -389,7 +386,7 @@ mod tests { use tracing_subscriber::{Layer, Registry}; use super::*; - fn settings(source: &str) -> ResolvedServerSettings { + fn settings(source: &str) -> ServerNamespace { let file = parse_settings_layer(source).expect("fixture should parse"); resolve_server_from_file(&file).expect("fixture should resolve") } @@ -563,7 +560,7 @@ methods = [] let errors = resolve_server_from_file(&file).expect_err("empty auth methods should fail"); assert!(errors.iter().any(|err| matches!( err, - fabro_config::resolve::ResolveError::Invalid { path, reason } + fabro_config::ResolveError::Invalid { path, reason } if path == "server.auth.methods" && reason.contains("must not be empty") ))); } diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 229c71af4..96e48a8c1 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -31,7 +31,6 @@ pub mod security_headers; pub mod serve; pub mod server; mod server_secrets; -mod settings_view; mod spawn_env; mod startup; pub mod static_files; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index ded7f88c1..cae7fd3e3 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::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; +use fabro_config::effective_settings::EffectiveSettingsLayers; use fabro_config::merge::combine_files; use fabro_config::project::resolve_working_directory; use fabro_config::run::parse_run_config; @@ -23,10 +23,10 @@ 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, RunSandboxLayer, - RunSettings, + RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunNamespace, + RunSandboxLayer, }; -use fabro_types::settings::{ServerSettings, SettingsLayer}; +use fabro_types::settings::{ServerNamespace, SettingsLayer}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; @@ -51,10 +51,9 @@ pub(crate) struct PreparedManifest { pub working_directory: PathBuf, } -pub(crate) fn prepare_manifest_with_mode( +pub(crate) fn prepare_manifest( server_settings: &SettingsLayer, manifest: &types::RunManifest, - local_daemon_mode: bool, ) -> Result { if manifest.version != 1 { bail!("unsupported manifest version {}", manifest.version); @@ -88,11 +87,6 @@ pub(crate) fn prepare_manifest_with_mode( let mut settings = effective_settings::materialize_settings_layer( EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer), Some(server_settings), - if local_daemon_mode { - EffectiveSettingsMode::LocalDaemon - } else { - EffectiveSettingsMode::RemoteServer - }, )?; if let Some(goal) = manifest.goal.as_ref() { let run = settings.run.get_or_insert_with(RunLayer::default); @@ -367,9 +361,9 @@ async fn build_preflight_report( &configured_providers, ); let resolved_run = fabro_config::resolve_run_from_file(&materialized) - .map_err(|errors| anyhow!(render_resolve_errors(&errors)))?; - let resolved_server = fabro_config::resolve_server_from_file(settings) - .map_err(|errors| anyhow!(render_resolve_errors(&errors)))?; + .map_err(|errors| anyhow!(fabro_config::render_resolve_errors(&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() { @@ -377,11 +371,11 @@ async fn build_preflight_report( } else { sandbox_provider }; - let needs_github_credentials = sandbox_provider == SandboxProvider::Daytona - || !resolved_server.integrations.github.permissions.is_empty(); + let needs_github_credentials = + sandbox_provider == SandboxProvider::Daytona || !github_integration.permissions.is_empty(); let github_app = if needs_github_credentials { state - .github_credentials(&resolved_server.integrations.github) + .github_credentials(github_integration) .unwrap_or_default() } else { None @@ -405,7 +399,7 @@ async fn build_preflight_report( &configured_providers, ) .await; - run_github_token_check(&mut checks, prepared, &resolved_server, github_app).await; + run_github_token_check(&mut checks, prepared, &server_settings.server, github_app).await; let checks_ok = sandbox_ok && llm_ok; @@ -471,7 +465,7 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec Result { +fn resolve_sandbox_provider(settings: &RunNamespace) -> Result { Ok(Some(str::parse::( settings.sandbox.provider.as_str(), )) @@ -480,7 +474,7 @@ fn resolve_sandbox_provider(settings: &RunSettings) -> Result { .unwrap_or_default()) } -fn resolve_daytona_config(settings: &RunSettings) -> Option { +fn resolve_daytona_config(settings: &RunNamespace) -> Option { settings .sandbox .daytona @@ -492,7 +486,7 @@ async fn run_sandbox_check( checks: &mut Vec, sandbox_provider: SandboxProvider, prepared: &PreparedManifest, - resolved_run: &RunSettings, + resolved_run: &RunNamespace, github_app: Option, daytona_api_key: Option, ) -> bool { @@ -567,7 +561,7 @@ async fn run_llm_check( state: &AppState, checks: &mut Vec, graph: &Graph, - settings: &RunSettings, + settings: &RunNamespace, configured_providers: &[Provider], ) -> bool { let (model, provider) = resolve_model_provider(settings, graph, configured_providers); @@ -679,7 +673,7 @@ async fn run_llm_check( } fn resolve_model_provider( - settings: &RunSettings, + settings: &RunNamespace, _graph: &Graph, configured_providers: &[Provider], ) -> (String, Option) { @@ -707,14 +701,6 @@ fn resolve_model_provider( } } -fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String { - errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("; ") -} - fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { DaytonaConfig { auto_stop_interval: settings.auto_stop_interval, @@ -753,7 +739,7 @@ fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { async fn run_github_token_check( checks: &mut Vec, prepared: &PreparedManifest, - settings: &ServerSettings, + settings: &ServerNamespace, github_app: Option, ) { if settings.integrations.github.permissions.is_empty() { @@ -990,7 +976,7 @@ root = "/srv/fabro" verbose: None, }); - let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap(); + let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); assert_eq!( fabro_config::resolve_run_from_file(&prepared.settings) @@ -1002,7 +988,7 @@ root = "/srv/fabro" } #[test] - fn prepare_manifest_local_daemon_prefers_bundled_settings_without_duplication() { + fn prepare_manifest_prefers_bundled_settings_without_duplication() { let server_settings = server_settings_fixture( r#" _version = 1 @@ -1050,7 +1036,7 @@ app_id = "snapshotted-app-id" type_: types::ManifestConfigType::User, }); - let prepared = prepare_manifest_with_mode(&server_settings, &manifest, true).unwrap(); + 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(); @@ -1075,9 +1061,7 @@ 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_with_mode(&default_settings_fixture(), &invalid_manifest(), false) - .unwrap(); + let prepared = prepare_manifest(&default_settings_fixture(), &invalid_manifest()).unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(validated.has_errors()); @@ -1112,8 +1096,7 @@ enabled = true type_: types::ManifestConfigType::Project, }); - let prepared = - prepare_manifest_with_mode(&default_settings_fixture(), &manifest, false).unwrap(); + let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); assert!(!validated.has_errors()); @@ -1150,8 +1133,7 @@ provider = "daytona" type_: types::ManifestConfigType::Project, }); - let prepared = - prepare_manifest_with_mode(&default_settings_fixture(), &manifest, false).unwrap(); + let prepared = prepare_manifest(&default_settings_fixture(), &manifest).unwrap(); let validated = validate_prepared_manifest(&prepared).unwrap(); let (response, _ok) = run_preflight(state.as_ref(), &prepared, &validated) diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index eaf64c5d3..955c9d955 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -7,8 +7,8 @@ use anyhow::Context; use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; use fabro_config::merge::combine_files; -use fabro_config::user::load_settings_config; -use fabro_config::{Storage, resolve_server_from_file}; +use fabro_config::user::{apply_storage_dir_override, load_settings_config}; +use fabro_config::{ServerSettings, 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::{ @@ -16,10 +16,11 @@ use fabro_types::settings::server::{ }; use fabro_types::settings::{ GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, - ServerSettings as ResolvedServerSettings, SettingsLayer, + ServerNamespace, SettingsLayer, }; use fabro_util::terminal::Styles; use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey}; +use object_store::client::{HttpClient, HttpConnector}; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; use object_store::{ClientOptions, ObjectStore, RetryConfig}; @@ -58,6 +59,56 @@ impl Default for ObjectStoreBuildOptions { } } +/// `HttpConnector` that builds a `reqwest::Client` with `.no_proxy()`. +/// +/// The object_store default `ReqwestConnector` calls `reqwest::Client::new()`, +/// which on macOS probes `SystemConfiguration` for proxies every time it runs. +/// That probe can stall long enough to blow past test timeouts. S3/MinIO +/// traffic goes directly to the configured endpoint, so skipping proxy +/// discovery is safe and keeps startup predictable. +#[derive(Debug)] +struct NoProxyReqwestConnector; + +impl HttpConnector for NoProxyReqwestConnector { + #[expect( + clippy::disallowed_methods, + reason = "object_store pins reqwest 0.12 and object_store::HttpClient::new requires \ + that exact version; we can't route through fabro_http (reqwest 0.13)" + )] + fn connect(&self, options: &ClientOptions) -> object_store::Result { + let mut builder = object_store_reqwest::Client::builder().no_proxy(); + if let Some(raw) = options.get_config_value(&object_store::ClientConfigKey::Timeout) { + if let Some(duration) = parse_config_duration(&raw) { + builder = builder.timeout(duration); + } + } + if let Some(raw) = options.get_config_value(&object_store::ClientConfigKey::ConnectTimeout) + { + if let Some(duration) = parse_config_duration(&raw) { + builder = builder.connect_timeout(duration); + } + } + let client = builder + .build() + .map_err(|err| object_store::Error::Generic { + store: "object_store", + source: Box::new(err), + })?; + Ok(HttpClient::new(client)) + } +} + +fn parse_config_duration(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(ms) = raw.strip_suffix("ms") { + return ms.trim().parse::().ok().map(Duration::from_millis); + } + if let Some(s) = raw.strip_suffix('s') { + return s.trim().parse::().ok().map(Duration::from_secs); + } + None +} + #[derive(Clone, Copy)] enum ServerTitlePhase { Boot, @@ -108,12 +159,7 @@ pub struct ServeArgs { pub watch_web: bool, } -fn load_settings(path: Option<&Path>) -> anyhow::Result { - Ok(load_settings_config(path)?) -} - fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLayer { - use fabro_types::settings::cli::CliLayer; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{RunLayer, RunModelLayer, RunSandboxLayer}; use fabro_types::settings::server::{ServerLayer, ServerWebLayer}; @@ -138,8 +184,6 @@ fn apply_serve_overrides(base: &SettingsLayer, args: &ServeArgs) -> SettingsLaye let sandbox_layer = run.sandbox.get_or_insert_with(RunSandboxLayer::default); sandbox_layer.provider = Some(sandbox.to_string()); } - // CliLayer is namespaced; nothing to populate from flag overrides today. - let _ = CliLayer::default(); settings } @@ -148,23 +192,11 @@ fn apply_runtime_settings( args: &ServeArgs, data_dir: &Path, ) -> SettingsLayer { - use fabro_types::settings::interp::InterpString; - use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; - let mut settings = apply_serve_overrides(base, args); - let server = settings.server.get_or_insert_with(ServerLayer::default); - let storage = server - .storage - .get_or_insert_with(ServerStorageLayer::default); - storage.root = Some(InterpString::parse(&data_dir.to_string_lossy())); - settings -} - -fn router_web_enabled(settings: &ResolvedServerSettings) -> bool { - settings.web.enabled + apply_storage_dir_override(apply_serve_overrides(base, args), Some(data_dir)) } async fn resolve_github_webhook_ip_allowlist( - resolved_server_settings: &ResolvedServerSettings, + resolved_server_settings: &ServerNamespace, github_meta_resolver: &GitHubMetaResolver, ) -> anyhow::Result> { let config = resolve_ip_allowlist_config( @@ -184,7 +216,7 @@ async fn resolve_github_webhook_ip_allowlist( } async fn resolve_startup_github_webhook_ip_allowlist( - resolved_server_settings: &ResolvedServerSettings, + resolved_server_settings: &ServerNamespace, github_meta_resolver: &GitHubMetaResolver, webhook_secret_present: bool, ) -> anyhow::Result>> { @@ -245,7 +277,7 @@ fn resolve_webhook_preconditions( } async fn start_webhook_strategy( - resolved_server_settings: &ResolvedServerSettings, + resolved_server_settings: &ServerNamespace, state: &Arc, bind_addr: &Bind, webhook_secret_present: bool, @@ -424,6 +456,7 @@ where path_style, } => { let mut builder = AmazonS3Builder::new() + .with_http_connector(NoProxyReqwestConnector) .with_bucket_name(resolve_interp(bucket)?) .with_region(resolve_interp(region)?) .with_virtual_hosted_style_request(!*path_style); @@ -436,24 +469,17 @@ where } } -fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result { - resolve_server_from_file(file).map_err(|errors| { - anyhow::anyhow!( - "failed to resolve server settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) - }) +fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result { + ServerSettings::from_layer(file) + .map(|settings| settings.server) + .map_err(anyhow::Error::from) } pub fn resolve_runtime_server_settings_for_start( args: &ServeArgs, data_dir: &Path, -) -> anyhow::Result { - let disk_settings = load_settings(args.config.as_deref())?; +) -> 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) } @@ -494,7 +520,7 @@ fn bind_override_layer(bind: BindRequest) -> SettingsLayer { } fn resolved_bind_request( - resolved_server_settings: &ResolvedServerSettings, + resolved_server_settings: &ServerNamespace, ) -> anyhow::Result { match &resolved_server_settings.listen { ServerListenSettings::Unix { path } => Ok(BindRequest::Unix(resolve_interp_path(path)?)), @@ -513,16 +539,14 @@ fn resolve_interp_path(value: &InterpString) -> anyhow::Result { Ok(PathBuf::from(resolve_interp(value)?)) } -fn load_server_secrets_for_settings( - settings: &ResolvedServerSettings, -) -> anyhow::Result { +fn load_server_secrets_for_settings(settings: &ServerNamespace) -> anyhow::Result { let storage_root = resolve_interp_path(&settings.storage.root)?; let server_env_path = Storage::new(&storage_root).runtime_directory().env_path(); ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::from) } pub(crate) fn build_artifact_object_store_with_server_secrets( - settings: &ResolvedServerSettings, + settings: &ServerNamespace, server_secrets: &ServerSecrets, ) -> anyhow::Result<(Arc, String)> { let prefix = resolve_interp(&settings.artifacts.prefix)?; @@ -535,14 +559,14 @@ pub(crate) fn build_artifact_object_store_with_server_secrets( } pub fn build_artifact_object_store( - settings: &ResolvedServerSettings, + settings: &ServerNamespace, ) -> anyhow::Result<(Arc, String)> { let server_secrets = load_server_secrets_for_settings(settings)?; build_artifact_object_store_with_server_secrets(settings, &server_secrets) } fn build_slatedb_store_with_server_secrets( - settings: &ResolvedServerSettings, + settings: &ServerNamespace, server_secrets: &ServerSecrets, ) -> anyhow::Result<(Arc, String, Duration, bool)> { let prefix = resolve_interp(&settings.slatedb.prefix)?; @@ -561,7 +585,7 @@ fn build_slatedb_store_with_server_secrets( #[cfg(test)] fn build_slatedb_store( - settings: &ResolvedServerSettings, + settings: &ServerNamespace, ) -> anyhow::Result<(Arc, String, Duration, bool)> { let server_secrets = load_server_secrets_for_settings(settings)?; build_slatedb_store_with_server_secrets(settings, &server_secrets) @@ -591,7 +615,7 @@ where #[cfg(debug_assertions)] let watch_web = args.watch_web; let config_path = args.config.clone(); - let disk_settings = load_settings(config_path.as_deref())?; + let disk_settings = load_settings_config(config_path.as_deref())?; let disk_server_settings = resolve_server_settings(&disk_settings)?; let data_dir = match storage_dir_override { Some(path) => path, @@ -615,7 +639,7 @@ where 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; - let web_enabled = router_web_enabled(&resolved_server_settings); + let web_enabled = resolved_server_settings.web.enabled; let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?; let (object_store, slatedb_prefix, flush_interval, disk_cache) = @@ -648,7 +672,6 @@ where artifact_store, vault_path, server_secrets, - local_daemon_mode: true, env_lookup, http_client: None, })?; @@ -723,7 +746,7 @@ where interval.tick().await; // skip first immediate tick loop { interval.tick().await; - match load_settings(config_path_for_poll.as_deref()) { + match load_settings_config(config_path_for_poll.as_deref()) { Ok(new_disk_settings) => { let effective = apply_runtime_settings( &new_disk_settings, @@ -1037,9 +1060,10 @@ mod tests { 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, router_web_enabled, - server_bind_title, server_title, + resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, server_bind_title, + server_title, }; + use crate::server::create_app_state_with_options; fn parse_settings(source: &str) -> SettingsLayer { let mut layer = parse_settings_layer(source).expect("v2 fixture should parse"); @@ -1074,6 +1098,42 @@ mod tests { assert_eq!(storage_root.as_deref(), Some("/srv/fabro-storage")); } + #[test] + fn app_state_server_settings_use_effective_runtime_layer_storage_override() { + let base = parse_settings( + r#" +_version = 1 + +[server.storage] +root = "/srv/from-disk" +"#, + ); + let args = ServeArgs { + bind: None, + model: None, + provider: None, + sandbox: None, + web: false, + no_web: false, + max_concurrent_runs: None, + config: None, + #[cfg(debug_assertions)] + watch_web: false, + }; + + let effective = apply_runtime_settings(&base, &args, &PathBuf::from("/srv/from-runtime")); + let state = create_app_state_with_options(effective, 5); + + assert_eq!( + state.server_settings().server.storage.root.as_source(), + "/srv/from-runtime" + ); + assert_eq!( + state.server_storage_dir(), + PathBuf::from("/srv/from-runtime") + ); + } + #[test] fn apply_runtime_settings_enables_web_from_cli_flag() { let base = parse_settings( @@ -1205,7 +1265,7 @@ strategy = "token" let resolved = resolve_server_settings(&base).expect("settings should resolve"); - assert!(router_web_enabled(&resolved)); + assert!(resolved.web.enabled); } #[test] diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 9cc11700e..27e1d1e39 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -12,7 +12,7 @@ use axum::body::Body; use axum::body::to_bytes; use axum::extract::{self as axum_extract, DefaultBodyLimit, Path, Query, State}; use axum::http::request::Parts; -use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::http::{HeaderMap, Method, StatusCode, header}; use axum::middleware::{self}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; @@ -34,13 +34,13 @@ pub use fabro_api::types::{ RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, - SecretType as ApiSecretType, ServerSettings, SshAccessRequest, SshAccessResponse, + SecretType as ApiSecretType, SshAccessRequest, SshAccessResponse, StageStatus as ApiStageStatus, StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRunCounts, WriteBlobResponse, }; use fabro_auth::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{Storage, resolve_server_from_file}; +use fabro_config::{ServerSettings, Storage}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -69,9 +69,7 @@ use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, ServerLayer, }; -use fabro_types::settings::{ - InterpString, ServerSettings as ResolvedServerSettings, SettingsLayer, -}; +use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_types::{ ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, @@ -128,9 +126,7 @@ use crate::server_secrets::{ LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message, }; use crate::spawn_env::{apply_render_graph_env, apply_worker_env}; -use crate::{ - demo, diagnostics, run_manifest, security_headers, settings_view, static_files, web_auth, -}; +use crate::{demo, diagnostics, run_manifest, security_headers, static_files, web_auth}; pub(crate) type EnvLookup = Arc Option + Send + Sync>; @@ -580,8 +576,7 @@ pub struct AppState { pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, pub(crate) settings: Arc>, - pub(crate) server_settings: RwLock>, - pub(crate) local_daemon_mode: bool, + pub(crate) server_settings: RwLock>, pub(crate) env_lookup: EnvLookup, http_client: Option, shutting_down: AtomicBool, @@ -598,7 +593,6 @@ pub(crate) struct AppStateConfig { pub(crate) artifact_store: ArtifactStore, pub(crate) vault_path: PathBuf, pub(crate) server_secrets: ServerSecrets, - pub(crate) local_daemon_mode: bool, pub(crate) env_lookup: EnvLookup, pub(crate) http_client: Option, } @@ -647,7 +641,7 @@ fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelU } impl AppState { - pub(crate) fn server_settings(&self) -> Arc { + pub(crate) fn server_settings(&self) -> Arc { Arc::clone( &self .server_settings @@ -665,7 +659,7 @@ impl AppState { pub(crate) fn server_storage_dir(&self) -> PathBuf { PathBuf::from( - resolve_interp_string(&self.server_settings().storage.root) + resolve_interp_string(&self.server_settings().server.storage.root) .expect("server storage root should be resolved at startup"), ) } @@ -707,7 +701,7 @@ impl AppState { } pub(crate) fn canonical_origin(&self) -> Result { - resolve_canonical_origin(&self.server_settings(), &self.env_lookup) + resolve_canonical_origin(&self.server_settings().server, &self.env_lookup) } pub(crate) fn session_key(&self) -> Option { @@ -789,17 +783,8 @@ impl AppState { } pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> { - let resolved = Arc::new(resolve_server_from_file(&settings).map_err(|errors| { - anyhow::anyhow!( - "failed to resolve server settings:\n{}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("\n") - ) - })?); - resolve_canonical_origin(&resolved, &self.env_lookup).map_err(anyhow::Error::msg)?; + let resolved = Arc::new(ServerSettings::from_layer(&settings)?); + resolve_canonical_origin(&resolved.server, &self.env_lookup).map_err(anyhow::Error::msg)?; *self.settings.write().expect("settings lock poisoned") = settings; *self @@ -1320,60 +1305,23 @@ async fn health() -> Response { async fn get_server_settings( _auth: AuthenticatedService, State(state): State>, - Query(query): Query, ) -> Response { - let settings = state.settings.read().unwrap().clone(); - match query.view { - settings_view::SettingsApiView::Layer => { - let redacted = settings_view::redact_for_api(&settings); - let mut value = match serde_json::to_value(&redacted) { - Ok(value) => value, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; - strip_nulls(&mut value); - (StatusCode::OK, Json(value)).into_response() - } - settings_view::SettingsApiView::Resolved => { - let resolved = match fabro_config::resolve(&settings) { - Ok(settings) => settings, - Err(err) => { - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to resolve settings: {err:?}"), - ) - .into_response(); - } - }; - let mut value = match settings_view::redact_resolved_value(&resolved) { - Ok(value) => value, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; - strip_nulls(&mut value); - let mut response = (StatusCode::OK, Json(value)).into_response(); - response.headers_mut().insert( - settings_view::RESOLVED_VIEW_HEADER_NAME, - HeaderValue::from_static(settings_view::RESOLVED_VIEW_HEADER_VALUE), - ); - response - } - } -} - -fn strip_nulls(value: &mut serde_json::Value) { - settings_view::strip_nulls(value); + ( + StatusCode::OK, + Json(state.server_settings().as_ref().clone()), + ) + .into_response() } async fn get_system_info( _auth: AuthenticatedService, State(state): State>, ) -> Response { - let settings = state.settings.read().unwrap().clone(); + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); let (total_runs, active_runs) = { let runs = state.runs.lock().expect("runs lock poisoned"); let active = runs @@ -1714,36 +1662,6 @@ fn system_sandbox_provider(settings: &SettingsLayer) -> String { ) } -fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String { - errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("; ") -} - -fn resolved_storage_dir(settings: &SettingsLayer) -> Result { - let resolved = - resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?; - resolved - .storage - .root - .resolve(|name| std::env::var(name).ok()) - .map(|value| PathBuf::from(value.value)) - .map_err(|err| { - format!( - "failed to resolve {}: {err}", - resolved.storage.root.as_source() - ) - }) -} - -fn resolved_github_settings(settings: &SettingsLayer) -> Result { - let resolved = - resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?; - Ok(resolved.integrations.github) -} - fn parse_system_duration(raw: &str) -> anyhow::Result { let raw = raw.trim(); anyhow::ensure!(!raw.is_empty(), "empty duration string"); @@ -1958,7 +1876,7 @@ async fn get_github_repo( return response; } let settings = state.server_settings(); - let github_settings = &settings.integrations.github; + let github_settings = &settings.server.integrations.github; let base_url = fabro_github::github_api_base_url(); let mut client: Option = None; let token = match github_settings.strategy { @@ -2462,11 +2380,9 @@ pub fn create_app_state_with_options_and_registry_factory( registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { let env_lookup = default_env_lookup(); - let mut config = default_test_app_state_config( - Arc::new(RwLock::new(settings)), - max_concurrent_runs, - 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") } @@ -2476,9 +2392,11 @@ pub fn create_app_state_with_options( settings: SettingsLayer, 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( - Arc::new(RwLock::new(settings)), + settings, max_concurrent_runs, env_lookup, )) @@ -2508,11 +2426,9 @@ pub fn create_app_state_with_env_lookup_and_server_secret_env( ) -> Arc { let (store, artifact_store) = test_store_bundle(); let env_lookup: EnvLookup = Arc::new(env_lookup); - let mut config = default_test_app_state_config( - Arc::new(RwLock::new(settings)), - max_concurrent_runs, - 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"); @@ -2528,7 +2444,6 @@ pub fn create_app_state_with_env_lookup_and_server_secret_env( pub(crate) fn create_test_app_state_with_session_key( settings: SettingsLayer, session_secret: Option<&str>, - local_daemon_mode: bool, ) -> Arc { let vault_path = test_secret_store_path(); let server_env_path = vault_path @@ -2554,7 +2469,6 @@ pub(crate) fn create_test_app_state_with_session_key( artifact_store, vault_path, server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), - local_daemon_mode, env_lookup, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), }) @@ -2590,7 +2504,6 @@ fn default_test_app_state_config( artifact_store, vault_path, server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), - local_daemon_mode: false, env_lookup, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), } @@ -2635,6 +2548,7 @@ fn create_app_state_with_store_and_env_lookup( 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; @@ -2659,7 +2573,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result>() - .join("\n") - ) - })?) + Arc::new(ServerSettings::from_layer(&settings)?) }; let slack_service = { - resolved_server_settings + current_server_settings + .server .integrations .slack .default_channel @@ -2721,8 +2626,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result req, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; - let prepared = match run_manifest::prepare_manifest_with_mode( - &state.settings.read().unwrap(), + let prepared = match run_manifest::prepare_manifest( + &state.settings.read().expect("settings lock poisoned"), &req, - state.local_daemon_mode, ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), @@ -4218,10 +4122,9 @@ async fn run_preflight( State(state): State>, Json(req): Json, ) -> Response { - let prepared = match run_manifest::prepare_manifest_with_mode( - &state.settings.read().unwrap(), + let prepared = match run_manifest::prepare_manifest( + &state.settings.read().expect("settings lock poisoned"), &req, - state.local_daemon_mode, ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), @@ -4248,10 +4151,9 @@ async fn render_graph_from_manifest( State(state): State>, Json(req): Json, ) -> Response { - let prepared = match run_manifest::prepare_manifest_with_mode( - &state.settings.read().unwrap(), + let prepared = match run_manifest::prepare_manifest( + &state.settings.read().expect("settings lock poisoned"), &req.manifest, - state.local_daemon_mode, ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), @@ -4343,26 +4245,17 @@ async fn start_run( } } - let Some(run_spec) = run_state.spec.as_ref() else { + if run_state.spec.is_none() { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "run spec missing from store", ) .into_response(); - }; - let run_dir = match resolved_storage_dir(&run_spec.settings) { - Ok(storage_dir) => Storage::new(storage_dir) - .run_scratch(&id) - .root() - .to_path_buf(), - Err(err) => { - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("invalid persisted server storage settings: {err}"), - ) - .into_response(); - } - }; + } + let run_dir = Storage::new(state.server_storage_dir()) + .run_scratch(&id) + .root() + .to_path_buf(); let dot_source = run_state.graph_source.unwrap_or_default(); if let Err(err) = workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunQueued).await @@ -4529,22 +4422,8 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { return; } }; - let github_settings = match resolved_github_settings(&persisted.run_spec().settings) { - Ok(settings) => settings, - Err(err) => { - tracing::error!(run_id = %run_id, error = %err, "Invalid GitHub integration config"); - let mut runs = state.runs.lock().expect("runs lock poisoned"); - if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed { - reason: FailureReason::WorkflowError, - }; - managed_run.error = Some(format!("Invalid GitHub integration config: {err}")); - clear_live_run_state(managed_run); - } - state.scheduler_notify.notify_one(); - return; - } - }; + 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, ) { @@ -4553,10 +4432,10 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { && settings.sandbox.provider == "daytona") || !github_settings.permissions.is_empty(); if required_github_credentials { - state.github_credentials(&github_settings) + state.github_credentials(github_settings) } else if settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some() { - match state.github_credentials(&github_settings) { + match state.github_credentials(github_settings) { Ok(github_app) => Ok(github_app), Err(err) => { tracing::warn!( @@ -4589,6 +4468,16 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { return; } }; + let github_permissions = github_settings + .permissions + .iter() + .map(|(name, value)| { + let resolved = value + .resolve(|env| std::env::var(env).ok()) + .map_or_else(|_| value.as_source(), |resolved| resolved.value); + (name.clone(), resolved) + }) + .collect(); let services = operations::StartServices { run_id, cancel_token: Some(Arc::clone(&cancel_token)), @@ -4599,6 +4488,7 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { artifact_sink: Some(ArtifactSink::Store(state.artifact_store.clone())), run_control: None, github_app, + github_permissions, vault: Some(Arc::clone(&state.vault)), on_node: None, registry_override, @@ -5105,16 +4995,7 @@ async fn get_run_settings( let Some(run_spec) = run_state.spec else { return ApiError::not_found("Run not found.").into_response(); }; - let redacted = settings_view::redact_for_api(&run_spec.settings); - let mut value = match serde_json::to_value(&redacted) { - Ok(value) => value, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; - strip_nulls(&mut value); - (StatusCode::OK, Json(value)).into_response() + (StatusCode::OK, Json(run_spec.settings)).into_response() } async fn get_questions( @@ -7535,34 +7416,6 @@ url = "{url}" .expect("settings fixture should parse") } - #[tokio::test] - async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() { - let state = create_app_state(); - *state.settings.write().unwrap() = fabro_config::parse_settings_layer( - r#" -_version = 1 - -[cli.target] -type = "http" -"#, - ) - .expect("settings fixture should parse"); - let app = build_router(state, AuthMode::Disabled); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri(api("/settings?view=resolved")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_status!(response, StatusCode::INTERNAL_SERVER_ERROR).await; - } - #[test] fn replace_settings_rejects_invalid_canonical_origin_and_keeps_previous_settings() { for invalid in ["", "/relative/path", "ftp://fabro.example.com"] { @@ -7590,6 +7443,65 @@ type = "http" } } + #[test] + fn replace_settings_updates_layer_and_typed_server_settings() { + let state = create_app_state_with_options( + fabro_config::parse_settings_layer( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://old.example.com" + +[server.storage] +root = "/srv/old" +"#, + ) + .expect("settings fixture should parse"), + 5, + ); + + let updated = fabro_config::parse_settings_layer( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.web] +url = "http://new.example.com" + +[server.storage] +root = "/srv/new" +"#, + ) + .expect("settings fixture should parse"); + + state + .replace_settings(updated) + .expect("valid settings should replace current state"); + + assert_eq!(state.canonical_origin().unwrap(), "http://new.example.com"); + assert_eq!( + state.server_settings().server.storage.root.as_source(), + "/srv/new" + ); + + let layer_root = state + .settings + .read() + .expect("settings lock poisoned") + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(InterpString::as_source); + assert_eq!(layer_root.as_deref(), Some("/srv/new")); + } + #[tokio::test] async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() { let state = create_app_state(); @@ -8355,7 +8267,6 @@ slug = "fabro" create_test_app_state_with_session_key( settings, Some("github-redirect-test-key-0123456789"), - false, ), AuthMode::Enabled(ConfiguredAuth { methods: vec![ServerAuthMethod::Github], @@ -8866,7 +8777,6 @@ slug = "fabro" let state = create_test_app_state_with_session_key( SettingsLayer::default(), Some("server-test-session-key-0123456789"), - false, ); let app = build_router( Arc::clone(&state), @@ -10071,7 +9981,6 @@ level = "debug" .spec .expect("run spec should exist"); let resolved_run = fabro_config::resolve_run_from_file(&run_spec.settings).unwrap(); - let resolved_server = fabro_config::resolve_server_from_file(&run_spec.settings).unwrap(); // Verify a sampling of the persisted v2 settings, including inherited // run execution mode from server settings. @@ -10097,16 +10006,18 @@ level = "debug" .as_deref(), Some("claude-sonnet-4-5"), ); - assert_eq!( - resolved_server + + // 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 - .github - .app_id .as_ref() - .map(fabro_types::settings::InterpString::as_source) - .as_deref(), - Some("12345"), - ); + .and_then(|integrations| integrations.github.as_ref()) + .and_then(|github| github.app_id.as_ref()) + .is_none() + })); } #[tokio::test] diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs deleted file mode 100644 index fe2640c53..000000000 --- a/lib/crates/fabro-server/src/settings_view.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Outward-facing view of [`SettingsLayer`] for API responses. -//! -//! `/api/v1/settings` and `/api/v1/runs/:id/settings` return the server's v2 -//! [`SettingsLayer`] directly as JSON so authenticated clients (the `fabro -//! settings` CLI, the web UI) can see the effective configuration. Before -//! serialization, this module drops the handful of fields that would leak -//! host-specific filesystem or network layout. -//! -//! ## What gets dropped -//! -//! Per the requirements doc, only the transport bind needs redaction now: -//! -//! - `server.listen` — the whole subtree. Bind addresses and socket paths -//! reveal network topology and host filesystem layout. -//! -//! ## Why that's all -//! -//! The rest of the v2 tree is either: -//! -//! - A literal non-secret value (storage root, scheduler limit, integration -//! slug, feature flag), OR -//! - An [`InterpString`] containing `{{ env.NAME }}` tokens. `InterpString`'s -//! default serialization preserves the *unresolved* template form, so the -//! wire payload surfaces `"Bearer {{ env.TOKEN }}"` instead of the resolved -//! secret value. No additional redaction pass is needed. -//! -//! Any future field that carries a raw secret in-band (without env -//! interpolation) must be added to the drop list below. - -use fabro_types::settings::{Settings, SettingsLayer}; -use serde::Deserialize; - -pub(crate) const RESOLVED_VIEW_HEADER_NAME: &str = "X-Fabro-Settings-View"; -pub(crate) const RESOLVED_VIEW_HEADER_VALUE: &str = "resolved"; - -const REDACTED_PATHS: &[&[&str]] = &[&["server", "listen"]]; - -#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum SettingsApiView { - #[default] - Layer, - Resolved, -} - -#[derive(Debug, Clone, Copy, Default, Deserialize)] -pub(crate) struct SettingsQuery { - #[serde(default)] - pub(crate) view: SettingsApiView, -} - -/// Build a redacted clone of `settings` safe to serialize outward. -/// -/// See the module docs for the drop-list rationale. -#[must_use] -pub(crate) fn redact_for_api(settings: &SettingsLayer) -> SettingsLayer { - let mut out = settings.clone(); - - if let Some(server) = out.server.as_mut() { - server.listen = None; - } - - out -} - -pub(crate) fn redact_resolved_value(settings: &Settings) -> serde_json::Result { - let mut value = serde_json::to_value(settings)?; - redact_value_paths(&mut value); - Ok(value) -} - -pub(crate) fn strip_nulls(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - for child in map.values_mut() { - strip_nulls(child); - } - map.retain(|_, child| !child.is_null()); - } - serde_json::Value::Array(values) => { - for child in values { - strip_nulls(child); - } - } - _ => {} - } -} - -fn redact_value_paths(value: &mut serde_json::Value) { - for path in REDACTED_PATHS { - remove_path(value, path); - } -} - -fn remove_path(value: &mut serde_json::Value, path: &[&str]) { - let Some((head, tail)) = path.split_first() else { - return; - }; - - let Some(object) = value.as_object_mut() else { - return; - }; - - if tail.is_empty() { - object.remove(*head); - return; - } - - if let Some(child) = object.get_mut(*head) { - remove_path(child, tail); - } -} - -#[cfg(test)] -mod tests { - use fabro_config::parse_settings_layer; - - use super::*; - - fn parse(source: &str) -> SettingsLayer { - parse_settings_layer(source).expect("fixture should parse") - } - - #[test] - fn drops_server_listen_entirely() { - let settings = parse( - r#" -_version = 1 - -[server.listen] -type = "tcp" -address = "127.0.0.1:32276" -"#, - ); - let redacted = redact_for_api(&settings); - assert!(redacted.server.unwrap().listen.is_none()); - } - - #[test] - fn preserves_run_cli_project_and_features() { - let settings = parse( - r#" -_version = 1 - -[project] -name = "Fabro" - -[run] -goal = "ship it" - -[run.model] -provider = "anthropic" -name = "sonnet" - -[cli.output] -verbosity = "verbose" - -[features] -session_sandboxes = true - -[server.scheduler] -max_concurrent_runs = 9 - -[server.auth] -methods = ["dev-token", "github"] - -[server.auth.github] -allowed_usernames = ["alice"] - -[server.storage] -root = "/srv/fabro" - -[server.integrations.github] -app_id = "12345" -client_id = "Iv1.abcdef" -slug = "fabro-app" -"#, - ); - let redacted = redact_for_api(&settings); - assert!(redacted.project.is_some()); - let run = redacted.run.unwrap(); - assert!(run.goal.is_some()); - assert!(run.model.is_some()); - assert!(redacted.cli.is_some()); - assert!(redacted.features.is_some()); - let server = redacted.server.unwrap(); - assert_eq!( - server.scheduler.and_then(|s| s.max_concurrent_runs), - Some(9) - ); - assert!(server.storage.is_some()); - let github = server.integrations.unwrap().github.unwrap(); - assert!(github.app_id.is_some()); - assert!(github.client_id.is_some()); - assert!(github.slug.is_some()); - let auth = server.auth.unwrap(); - assert_eq!(auth.methods.unwrap().len(), 2); - assert_eq!(auth.github.unwrap().allowed_usernames, vec!["alice"]); - } - - #[test] - fn preserves_env_templates_for_non_redacted_fields() { - let settings = parse( - r#" -_version = 1 - -[server.storage] -root = "{{ env.FABRO_STORAGE_ROOT }}" - -[server.integrations.slack] -default_channel = "{{ env.SLACK_CHANNEL }}" -"#, - ); - - let redacted = redact_for_api(&settings); - let server = redacted - .server - .expect("server config should remain present"); - assert_eq!( - server - .storage - .and_then(|storage| storage.root) - .map(|value| value.as_source()), - Some("{{ env.FABRO_STORAGE_ROOT }}".to_string()) - ); - assert_eq!( - server - .integrations - .and_then(|integrations| integrations.slack) - .and_then(|slack| slack.default_channel) - .map(|value| value.as_source()), - Some("{{ env.SLACK_CHANNEL }}".to_string()) - ); - } - - #[test] - fn redacts_dense_resolved_settings_with_the_same_secret_paths() { - let settings = parse( - r#" -_version = 1 - -[server.listen] -type = "tcp" -address = "127.0.0.1:32276" - -[server.auth] -methods = ["github", "dev-token"] - -[server.auth.github] -allowed_usernames = ["alice"] - -[server.storage] -root = "{{ env.FABRO_STORAGE_ROOT }}" -"#, - ); - - let resolved = fabro_config::resolve(&settings).expect("settings should resolve"); - let mut redacted = - redact_resolved_value(&resolved).expect("resolved settings should serialize"); - - assert!(redacted["server"].get("listen").is_none()); - assert_eq!(redacted["server"]["auth"]["methods"][0], "github"); - assert_eq!( - redacted["server"]["auth"]["github"]["allowed_usernames"][0], - "alice" - ); - assert_eq!( - redacted["server"]["storage"]["root"], - "{{ env.FABRO_STORAGE_ROOT }}" - ); - - strip_nulls(&mut redacted); - assert!(redacted["server"].get("listen").is_none()); - } -} diff --git a/lib/crates/fabro-server/src/startup.rs b/lib/crates/fabro-server/src/startup.rs index 3817c5e6a..6bd2c957f 100644 --- a/lib/crates/fabro-server/src/startup.rs +++ b/lib/crates/fabro-server/src/startup.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::Path; -use fabro_types::settings::ServerSettings as ResolvedServerSettings; +use fabro_types::settings::ServerNamespace; use crate::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use crate::server_secrets::ServerSecrets; @@ -9,7 +9,7 @@ use crate::server_secrets::ServerSecrets; pub(crate) fn resolve_startup( env_path: &Path, env_entries: HashMap, - settings: &ResolvedServerSettings, + settings: &ServerNamespace, ) -> anyhow::Result<(AuthMode, ServerSecrets)> { let server_secrets = ServerSecrets::load(env_path, env_entries)?; let auth_mode = resolve_auth_mode_with_lookup(settings, |name| server_secrets.get(name))?; @@ -19,7 +19,7 @@ pub(crate) fn resolve_startup( pub fn validate_startup( env_path: &Path, env_entries: HashMap, - settings: &ResolvedServerSettings, + settings: &ServerNamespace, ) -> anyhow::Result<()> { resolve_startup(env_path, env_entries, settings).map(|_| ()) } @@ -29,11 +29,11 @@ mod tests { use std::collections::HashMap; use fabro_config::parse_settings_layer; - use fabro_types::settings::ServerSettings as ResolvedServerSettings; + use fabro_types::settings::ServerNamespace; use super::validate_startup; - fn resolved_settings(auth_methods: &[&str]) -> ResolvedServerSettings { + fn resolved_settings(auth_methods: &[&str]) -> ServerNamespace { let settings = parse_settings_layer(&format!( r" _version = 1 diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index d061915d8..410e29bd0 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -290,6 +290,7 @@ fn session_provider(auth_method: RunAuthMethod) -> &'static str { fn session_cookie_secure(state: &AppState) -> bool { state .server_settings() + .server .web .url .resolve(|name| std::env::var(name).ok()) @@ -374,7 +375,7 @@ async fn login_github( ); }; let settings = state.server_settings(); - let Some(client_id) = settings.integrations.github.client_id.as_ref() else { + let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else { warn!("OAuth login failed: client_id not configured"); return json_response( StatusCode::CONFLICT, @@ -516,7 +517,7 @@ async fn callback_github( .as_deref() .expect("validated oauth callback state should exist"); - let Some(client_id) = settings.integrations.github.client_id.as_ref() else { + let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else { error!("OAuth callback failed: client_id not configured"); return json_response( StatusCode::CONFLICT, @@ -686,7 +687,7 @@ async fn callback_github( _ => Vec::new(), }; - let allowed_usernames = settings.auth.github.allowed_usernames.clone(); + let allowed_usernames = settings.server.auth.github.allowed_usernames.clone(); if !allowed_usernames.iter().any(|user| user == &profile.login) { warn!(login = %profile.login, "OAuth callback denied: username not in allowlist"); return callback_error_redirect( @@ -909,7 +910,6 @@ mod tests { let state = server::create_test_app_state_with_session_key( settings, Some("web-auth-test-key-material-0123456789"), - false, ); let middleware_state = state.clone(); axum::Router::new() @@ -1105,7 +1105,6 @@ mod tests { let state = server::create_test_app_state_with_session_key( github_settings("https://fabro.example"), Some("web-auth-test-key-material-0123456789"), - false, ); let app = server::build_router_with_options( state, @@ -1198,7 +1197,6 @@ mod tests { let state = server::create_test_app_state_with_session_key( github_settings("https://fabro.example"), Some("web-auth-test-key-material-0123456789"), - false, ); let app = crate::server::build_router_with_options( state, diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index 061b87e80..758c69d07 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -3,7 +3,6 @@ use axum::http::{Request, StatusCode}; use fabro_config::parse_settings_layer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; -use serde_json::json; use tower::ServiceExt; use crate::helpers::{ @@ -11,7 +10,7 @@ use crate::helpers::{ }; #[tokio::test] -async fn retrieve_run_settings_preserves_templates_and_redacts_sensitive_fields() { +async fn retrieve_run_settings_returns_persisted_layer_without_redaction() { let storage_dir = tempfile::tempdir().unwrap(); let settings = parse_settings_layer(&format!( r#" @@ -97,21 +96,11 @@ session_sandboxes = true storage_dir.path().display().to_string() ); assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); - assert_eq!( - body["server"]["integrations"]["github"]["app_id"], - "{{ env.GITHUB_APP_ID }}" - ); - assert_eq!( - body["server"]["integrations"]["github"]["client_id"], - "Iv1.github" - ); - assert_eq!( - body["server"]["auth"]["methods"], - json!(["dev-token", "github"]) - ); - assert_eq!( - body["server"]["auth"]["github"]["allowed_usernames"], - json!(["alice"]) + 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()); } diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index 5c892313f..1495b43da 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -4,13 +4,12 @@ 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 serde_json::json; use tower::ServiceExt; -use crate::helpers::{body_json, checked_response, response_json}; +use crate::helpers::response_json; #[tokio::test] -async fn retrieve_server_settings_default_view_returns_redacted_layer_settings() { +async fn retrieve_server_settings_returns_dense_server_settings_from_app_state() { let settings: SettingsLayer = parse_settings_layer( r#" _version = 1 @@ -25,9 +24,6 @@ root = "/srv/fabro" [server.scheduler] max_concurrent_runs = 9 -[cli.output] -verbosity = "verbose" - [server.auth] methods = ["dev-token", "github"] @@ -36,9 +32,6 @@ allowed_usernames = ["alice"] [server.integrations.github] client_id = "Iv1.abcdef" - -[run.inputs] -server_only = "1" "#, ) .expect("settings fixture should parse"); @@ -55,102 +48,28 @@ server_only = "1" let response = app.oneshot(request).await.unwrap(); let body = response_json(response, StatusCode::OK, "GET /api/v1/settings").await; - assert_eq!(body["_version"], 1); + let top_level = body + .as_object() + .expect("server settings response should be an object"); + assert_eq!(top_level.len(), 2); + assert!(top_level.contains_key("server")); + assert!(top_level.contains_key("features")); + + assert_eq!(body["server"]["listen"]["type"], "tcp"); + assert_eq!(body["server"]["listen"]["address"], "127.0.0.1:32276"); assert_eq!(body["server"]["storage"]["root"], "/srv/fabro"); assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); - assert_eq!(body["cli"]["output"]["verbosity"], "verbose"); - assert_eq!(body["run"]["inputs"]["server_only"], "1"); - assert!(body["server"].get("listen").is_none()); + assert_eq!(body["server"]["auth"]["methods"][0], "dev-token"); + assert_eq!(body["server"]["auth"]["methods"][1], "github"); assert_eq!( - body["server"]["auth"]["methods"], - json!(["dev-token", "github"]) - ); - assert_eq!( - body["server"]["auth"]["github"]["allowed_usernames"], - json!(["alice"]) - ); - assert_eq!( - body["server"]["integrations"]["github"]["client_id"], - "Iv1.abcdef" - ); -} - -#[tokio::test] -async fn retrieve_server_settings_resolved_view_returns_dense_settings_and_marker() { - let settings: SettingsLayer = parse_settings_layer( - r#" -_version = 1 - -[server.listen] -type = "tcp" -address = "127.0.0.1:32276" - -[server.storage] -root = "/srv/fabro" - -[server.auth] -methods = ["dev-token", "github"] - -[server.auth.github] -allowed_usernames = ["alice"] - -[server.integrations.github] -client_id = "Iv1.abcdef" - -[run.model] -provider = "openai" -name = "server-model" - -[run.inputs] -server_only = "1" -"#, - ) - .expect("settings fixture should parse"); - let app = build_router( - create_app_state_with_options(settings, 5), - AuthMode::Disabled, - ); - - let request = Request::builder() - .method("GET") - .uri("/api/v1/settings?view=resolved") - .body(Body::empty()) - .unwrap(); - let response = app.oneshot(request).await.unwrap(); - - let response = checked_response( - response, - StatusCode::OK, - "GET /api/v1/settings?view=resolved", - ) - .await; - assert_eq!( - response - .headers() - .get("x-fabro-settings-view") - .and_then(|value| value.to_str().ok()), - Some("resolved") - ); - let body = body_json(response.into_body()).await; - assert!(body.get("_version").is_none()); - assert_eq!(body["project"]["directory"], "."); - assert_eq!(body["workflow"]["graph"], "workflow.fabro"); - assert_eq!(body["run"]["execution"]["approval"], "prompt"); - assert_eq!(body["run"]["model"]["provider"], "openai"); - assert_eq!(body["run"]["model"]["name"], "server-model"); - assert_eq!(body["run"]["inputs"]["server_only"], "1"); - assert_eq!(body["server"]["storage"]["root"], "/srv/fabro"); - assert!(body["server"].get("listen").is_none()); - assert_eq!( - body["server"]["auth"]["methods"], - json!(["dev-token", "github"]) - ); - assert_eq!( - body["server"]["auth"]["github"]["allowed_usernames"], - json!(["alice"]) + body["server"]["auth"]["github"]["allowed_usernames"][0], + "alice" ); assert_eq!( body["server"]["integrations"]["github"]["client_id"], "Iv1.abcdef" ); + assert_eq!(body["features"]["session_sandboxes"], false); + assert!(body.get("cli").is_none()); + assert!(body.get("run").is_none()); } diff --git a/lib/crates/fabro-spa/assets/assets/entry-5kscg7r3.js b/lib/crates/fabro-spa/assets/assets/entry-1addt5fg.js similarity index 82% rename from lib/crates/fabro-spa/assets/assets/entry-5kscg7r3.js rename to lib/crates/fabro-spa/assets/assets/entry-1addt5fg.js index e5a1bac03..06852a53f 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-5kscg7r3.js +++ b/lib/crates/fabro-spa/assets/assets/entry-1addt5fg.js @@ -1,4 +1,4 @@ -import{X as h,Y as D3,Z as p5,_ as x}from"./chunk-q07bg6gn.js";var J0=D3((va,YU)=>{(function(){function Z(y,Y0){Object.defineProperty(z.prototype,y,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Y0[0],Y0[1])}})}function Y(y){if(y===null||typeof y!=="object")return null;return y=B1&&y[B1]||y["@@iterator"],typeof y==="function"?y:null}function Q(y,Y0){y=(y=y.constructor)&&(y.displayName||y.name)||"ReactClass";var A0=y+"."+Y0;R0[A0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Y0,y),R0[A0]=!0)}function z(y,Y0,A0){this.props=y,this.context=Y0,this.refs=n5,this.updater=A0||C1}function q(){}function B(y,Y0,A0){this.props=y,this.context=Y0,this.refs=n5,this.updater=A0||C1}function W(){}function $(y){return""+y}function U(y){try{$(y);var Y0=!1}catch(k0){Y0=!0}if(Y0){Y0=console;var A0=Y0.error,T0=typeof Symbol==="function"&&Symbol.toStringTag&&y[Symbol.toStringTag]||y.constructor.name||"Object";return A0.call(Y0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",T0),$(y)}}function M(y){if(y==null)return null;if(typeof y==="function")return y.$$typeof===t6?null:y.displayName||y.name||null;if(typeof y==="string")return y;switch(y){case K0:return"Fragment";case z0:return"Profiler";case f:return"StrictMode";case x0:return"Suspense";case H0:return"SuspenseList";case R1:return"Activity"}if(typeof y==="object")switch(typeof y.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),y.$$typeof){case Z0:return"Portal";case n:return y.displayName||"Context";case i:return(y._context.displayName||"Context")+".Consumer";case L0:var Y0=y.render;return y=y.displayName,y||(y=Y0.displayName||Y0.name||"",y=y!==""?"ForwardRef("+y+")":"ForwardRef"),y;case p0:return Y0=y.displayName||null,Y0!==null?Y0:M(y.type)||"Memo";case i0:Y0=y._payload,y=y._init;try{return M(y(Y0))}catch(A0){}}return null}function H(y){if(y===K0)return"<>";if(typeof y==="object"&&y!==null&&y.$$typeof===i0)return"<...>";try{var Y0=M(y);return Y0?"<"+Y0+">":"<...>"}catch(A0){return"<...>"}}function O(){var y=W1.A;return y===null?null:y.getOwner()}function _(){return Error("react-stack-top-frame")}function A(y){if(N4.call(y,"key")){var Y0=Object.getOwnPropertyDescriptor(y,"key").get;if(Y0&&Y0.isReactWarning)return!1}return y.key!==void 0}function P(y,Y0){function A0(){V6||(V6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Y0))}A0.isReactWarning=!0,Object.defineProperty(y,"key",{get:A0,configurable:!0})}function L(){var y=M(this.type);return d4[y]||(d4[y]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),y=this.props.ref,y!==void 0?y:null}function v(y,Y0,A0,T0,k0,Y1){var f0=A0.ref;return y={$$typeof:G0,type:y,key:Y0,props:A0,_owner:T0},(f0!==void 0?f0:null)!==null?Object.defineProperty(y,"ref",{enumerable:!1,get:L}):Object.defineProperty(y,"ref",{enumerable:!1,value:null}),y._store={},Object.defineProperty(y._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(y,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(y,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:k0}),Object.defineProperty(y,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(y.props),Object.freeze(y)),y}function R(y,Y0){return Y0=v(y.type,Y0,y.props,y._owner,y._debugStack,y._debugTask),y._store&&(Y0._store.validated=y._store.validated),Y0}function T(y){C(y)?y._store&&(y._store.validated=1):typeof y==="object"&&y!==null&&y.$$typeof===i0&&(y._payload.status==="fulfilled"?C(y._payload.value)&&y._payload.value._store&&(y._payload.value._store.validated=1):y._store&&(y._store.validated=1))}function C(y){return typeof y==="object"&&y!==null&&y.$$typeof===G0}function j(y){var Y0={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(A0){return Y0[A0]})}function E(y,Y0){return typeof y==="object"&&y!==null&&y.key!=null?(U(y.key),j(""+y.key)):Y0.toString(36)}function S(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status==="string"?y.then(W,W):(y.status="pending",y.then(function(Y0){y.status==="pending"&&(y.status="fulfilled",y.value=Y0)},function(Y0){y.status==="pending"&&(y.status="rejected",y.reason=Y0)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function I(y,Y0,A0,T0,k0){var Y1=typeof y;if(Y1==="undefined"||Y1==="boolean")y=null;var f0=!1;if(y===null)f0=!0;else switch(Y1){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(y.$$typeof){case G0:case Z0:f0=!0;break;case i0:return f0=y._init,I(f0(y._payload),Y0,A0,T0,k0)}}if(f0){f0=y,k0=k0(f0);var Q1=T0===""?"."+E(f0,0):T0;return H1(k0)?(A0="",Q1!=null&&(A0=Q1.replace(Y7,"$&/")+"/"),I(k0,Y0,A0,"",function(_5){return _5})):k0!=null&&(C(k0)&&(k0.key!=null&&(f0&&f0.key===k0.key||U(k0.key)),A0=R(k0,A0+(k0.key==null||f0&&f0.key===k0.key?"":(""+k0.key).replace(Y7,"$&/")+"/")+Q1),T0!==""&&f0!=null&&C(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(A0._store.validated=2),k0=A0),Y0.push(k0)),1}if(f0=0,Q1=T0===""?".":T0+":",H1(y))for(var j0=0;j0{(function(){function Z(j,Y0){Object.defineProperty(z.prototype,j,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Y0[0],Y0[1])}})}function Y(j){if(j===null||typeof j!=="object")return null;return j=B1&&j[B1]||j["@@iterator"],typeof j==="function"?j:null}function Q(j,Y0){j=(j=j.constructor)&&(j.displayName||j.name)||"ReactClass";var A0=j+"."+Y0;R0[A0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Y0,j),R0[A0]=!0)}function z(j,Y0,A0){this.props=j,this.context=Y0,this.refs=n5,this.updater=A0||C1}function q(){}function B(j,Y0,A0){this.props=j,this.context=Y0,this.refs=n5,this.updater=A0||C1}function W(){}function $(j){return""+j}function U(j){try{$(j);var Y0=!1}catch(k0){Y0=!0}if(Y0){Y0=console;var A0=Y0.error,T0=typeof Symbol==="function"&&Symbol.toStringTag&&j[Symbol.toStringTag]||j.constructor.name||"Object";return A0.call(Y0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",T0),$(j)}}function M(j){if(j==null)return null;if(typeof j==="function")return j.$$typeof===t6?null:j.displayName||j.name||null;if(typeof j==="string")return j;switch(j){case K0:return"Fragment";case z0:return"Profiler";case f:return"StrictMode";case x0:return"Suspense";case H0:return"SuspenseList";case R1:return"Activity"}if(typeof j==="object")switch(typeof j.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),j.$$typeof){case Z0:return"Portal";case n:return j.displayName||"Context";case i:return(j._context.displayName||"Context")+".Consumer";case L0:var Y0=j.render;return j=j.displayName,j||(j=Y0.displayName||Y0.name||"",j=j!==""?"ForwardRef("+j+")":"ForwardRef"),j;case p0:return Y0=j.displayName||null,Y0!==null?Y0:M(j.type)||"Memo";case i0:Y0=j._payload,j=j._init;try{return M(j(Y0))}catch(A0){}}return null}function H(j){if(j===K0)return"<>";if(typeof j==="object"&&j!==null&&j.$$typeof===i0)return"<...>";try{var Y0=M(j);return Y0?"<"+Y0+">":"<...>"}catch(A0){return"<...>"}}function O(){var j=W1.A;return j===null?null:j.getOwner()}function _(){return Error("react-stack-top-frame")}function A(j){if(N4.call(j,"key")){var Y0=Object.getOwnPropertyDescriptor(j,"key").get;if(Y0&&Y0.isReactWarning)return!1}return j.key!==void 0}function P(j,Y0){function A0(){V6||(V6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Y0))}A0.isReactWarning=!0,Object.defineProperty(j,"key",{get:A0,configurable:!0})}function L(){var j=M(this.type);return d4[j]||(d4[j]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),j=this.props.ref,j!==void 0?j:null}function v(j,Y0,A0,T0,k0,Y1){var f0=A0.ref;return j={$$typeof:G0,type:j,key:Y0,props:A0,_owner:T0},(f0!==void 0?f0:null)!==null?Object.defineProperty(j,"ref",{enumerable:!1,get:L}):Object.defineProperty(j,"ref",{enumerable:!1,value:null}),j._store={},Object.defineProperty(j._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(j,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(j,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:k0}),Object.defineProperty(j,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(j.props),Object.freeze(j)),j}function R(j,Y0){return Y0=v(j.type,Y0,j.props,j._owner,j._debugStack,j._debugTask),j._store&&(Y0._store.validated=j._store.validated),Y0}function T(j){C(j)?j._store&&(j._store.validated=1):typeof j==="object"&&j!==null&&j.$$typeof===i0&&(j._payload.status==="fulfilled"?C(j._payload.value)&&j._payload.value._store&&(j._payload.value._store.validated=1):j._store&&(j._store.validated=1))}function C(j){return typeof j==="object"&&j!==null&&j.$$typeof===G0}function y(j){var Y0={"=":"=0",":":"=2"};return"$"+j.replace(/[=:]/g,function(A0){return Y0[A0]})}function b(j,Y0){return typeof j==="object"&&j!==null&&j.key!=null?(U(j.key),y(""+j.key)):Y0.toString(36)}function S(j){switch(j.status){case"fulfilled":return j.value;case"rejected":throw j.reason;default:switch(typeof j.status==="string"?j.then(W,W):(j.status="pending",j.then(function(Y0){j.status==="pending"&&(j.status="fulfilled",j.value=Y0)},function(Y0){j.status==="pending"&&(j.status="rejected",j.reason=Y0)})),j.status){case"fulfilled":return j.value;case"rejected":throw j.reason}}throw j}function I(j,Y0,A0,T0,k0){var Y1=typeof j;if(Y1==="undefined"||Y1==="boolean")j=null;var f0=!1;if(j===null)f0=!0;else switch(Y1){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(j.$$typeof){case G0:case Z0:f0=!0;break;case i0:return f0=j._init,I(f0(j._payload),Y0,A0,T0,k0)}}if(f0){f0=j,k0=k0(f0);var Q1=T0===""?"."+b(f0,0):T0;return H1(k0)?(A0="",Q1!=null&&(A0=Q1.replace(Y7,"$&/")+"/"),I(k0,Y0,A0,"",function(_5){return _5})):k0!=null&&(C(k0)&&(k0.key!=null&&(f0&&f0.key===k0.key||U(k0.key)),A0=R(k0,A0+(k0.key==null||f0&&f0.key===k0.key?"":(""+k0.key).replace(Y7,"$&/")+"/")+Q1),T0!==""&&f0!=null&&C(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(A0._store.validated=2),k0=A0),Y0.push(k0)),1}if(f0=0,Q1=T0===""?".":T0+":",H1(j))for(var y0=0;y0 import('./MyComponent')) @@ -6,23 +6,23 @@ Your code should look like: Did you accidentally put curly braces around the import?`,Y0),"default"in Y0||console.error(`lazy: Expected the result of a dynamic import() call. Instead received: %s Your code should look like: - const MyComponent = lazy(() => import('./MyComponent'))`,Y0),Y0.default;throw y._result}function k(){var y=W1.H;return y===null&&console.error(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: + const MyComponent = lazy(() => import('./MyComponent'))`,Y0),Y0.default;throw j._result}function k(){var j=W1.H;return j===null&&console.error(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),y}function o(){W1.asyncTransitions--}function q0(y){if(O4===null)try{var Y0=("require"+Math.random()).slice(0,7);O4=(YU&&YU[Y0]).call(YU,"timers").setImmediate}catch(A0){O4=function(T0){$2===!1&&($2=!0,typeof MessageChannel>"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var k0=new MessageChannel;k0.port1.onmessage=T0,k0.port2.postMessage(void 0)}}return O4(y)}function $0(y){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(j0,_5){k0=!0,f0.then(function(K5){if(w0(Y0,A0),A0===0){try{X0(T0),q0(function(){return e(K5,j0,_5)})}catch(e5){W1.thrownErrors.push(e5)}if(0 ...)"))}),W1.actQueue=null),0W1.recentlyCreatedOwnerStacks++;return v(y,k0,T0,O(),j0?Error("react-stack-top-frame"):x2,j0?S1(H(y)):L6)},va.createRef=function(){var y={current:null};return Object.seal(y),y},va.forwardRef=function(y){y!=null&&y.$$typeof===p0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof y!=="function"?console.error("forwardRef requires a render function but was given %s.",y===null?"null":typeof y):y.length!==0&&y.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",y.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),y!=null&&y.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Y0={$$typeof:L0,render:y},A0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return A0},set:function(T0){A0=T0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:T0}),y.displayName=T0)}}),Y0},va.isValidElement=C,va.lazy=function(y){y={_status:-1,_result:y};var Y0={$$typeof:i0,_payload:y,_init:l},A0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return y._ioInfo=A0,Y0._debugInfo=[{awaited:A0}],Y0},va.memo=function(y,Y0){y==null&&console.error("memo: The first argument must be a component. Instead received: %s",y===null?"null":typeof y),Y0={$$typeof:p0,type:y,compare:Y0===void 0?null:Y0};var A0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return A0},set:function(T0){A0=T0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:T0}),y.displayName=T0)}}),Y0},va.startTransition=function(y){var Y0=W1.T,A0={};A0._updatedFibers=new Set,W1.T=A0;try{var T0=y(),k0=W1.S;k0!==null&&k0(A0,T0),typeof T0==="object"&&T0!==null&&typeof T0.then==="function"&&(W1.asyncTransitions++,T0.then(o,o),T0.then(W,C5))}catch(Y1){C5(Y1)}finally{Y0===null&&A0._updatedFibers&&(y=A0._updatedFibers.size,A0._updatedFibers.clear(),10{(function(){function Z(){if(j=!1,g){var e=Ca.unstable_now();o=e;var X0=!0;try{Z:{T=!1,C&&(C=!1,S(l),l=-1),R=!0;var G0=v;try{Y:{B(e);for(L=Q(_);L!==null&&!(L.expirationTime>e&&$());){var Z0=L.callback;if(typeof Z0==="function"){L.callback=null,v=L.priorityLevel;var K0=Z0(L.expirationTime<=e);if(e=Ca.unstable_now(),typeof K0==="function"){L.callback=K0,B(e),X0=!0;break Y}L===Q(_)&&z(_),B(e)}else z(_);L=Q(_)}if(L!==null)X0=!0;else{var f=Q(A);f!==null&&U(W,f.startTime-e),X0=!1}}break Z}finally{L=null,v=G0,R=!1}X0=void 0}}finally{X0?q0():g=!1}}}function Y(e,X0){var G0=e.length;e.push(X0);Z:for(;0>>1,K0=e[Z0];if(0>>1;Z0q(i,G0))nq(L0,i)?(e[Z0]=L0,e[n]=G0,Z0=n):(e[Z0]=i,e[z0]=G0,Z0=z0);else if(nq(L0,G0))e[Z0]=L0,e[n]=G0,Z0=n;else break Z}}return X0}function q(e,X0){var G0=e.sortIndex-X0.sortIndex;return G0!==0?G0:e.id-X0.id}function B(e){for(var X0=Q(A);X0!==null;){if(X0.callback===null)z(A);else if(X0.startTime<=e)z(A),X0.sortIndex=X0.expirationTime,Y(_,X0);else break;X0=Q(A)}}function W(e){if(C=!1,B(e),!T)if(Q(_)!==null)T=!0,g||(g=!0,q0());else{var X0=Q(A);X0!==null&&U(W,X0.startTime-e)}}function $(){return j?!0:Ca.unstable_now()-oe||125Z0?(e.sortIndex=G0,Y(A,e),Q(_)===null&&e===Q(A)&&(C?(S(l),l=-1):C=!0,U(W,G0-Z0))):(e.sortIndex=K0,Y(_,e),T||R||(T=!0,g||(g=!0,q0()))),e},Ca.unstable_shouldYield=$,Ca.unstable_wrapCallback=function(e){var X0=v;return function(){var G0=v;v=X0;try{return e.apply(this,arguments)}finally{v=G0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var dI=D3((Da)=>{var dA=h(J0());(function(){function Z(){}function Y(H){return""+H}function Q(H,O,_){var A=3"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var k0=new MessageChannel;k0.port1.onmessage=T0,k0.port2.postMessage(void 0)}}return O4(j)}function $0(j){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(y0,_5){k0=!0,f0.then(function(K5){if(w0(Y0,A0),A0===0){try{X0(T0),q0(function(){return e(K5,y0,_5)})}catch(e5){W1.thrownErrors.push(e5)}if(0 ...)"))}),W1.actQueue=null),0W1.recentlyCreatedOwnerStacks++;return v(j,k0,T0,O(),y0?Error("react-stack-top-frame"):x2,y0?S1(H(j)):L6)},va.createRef=function(){var j={current:null};return Object.seal(j),j},va.forwardRef=function(j){j!=null&&j.$$typeof===p0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof j!=="function"?console.error("forwardRef requires a render function but was given %s.",j===null?"null":typeof j):j.length!==0&&j.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",j.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),j!=null&&j.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Y0={$$typeof:L0,render:j},A0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return A0},set:function(T0){A0=T0,j.name||j.displayName||(Object.defineProperty(j,"name",{value:T0}),j.displayName=T0)}}),Y0},va.isValidElement=C,va.lazy=function(j){j={_status:-1,_result:j};var Y0={$$typeof:i0,_payload:j,_init:l},A0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return j._ioInfo=A0,Y0._debugInfo=[{awaited:A0}],Y0},va.memo=function(j,Y0){j==null&&console.error("memo: The first argument must be a component. Instead received: %s",j===null?"null":typeof j),Y0={$$typeof:p0,type:j,compare:Y0===void 0?null:Y0};var A0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return A0},set:function(T0){A0=T0,j.name||j.displayName||(Object.defineProperty(j,"name",{value:T0}),j.displayName=T0)}}),Y0},va.startTransition=function(j){var Y0=W1.T,A0={};A0._updatedFibers=new Set,W1.T=A0;try{var T0=j(),k0=W1.S;k0!==null&&k0(A0,T0),typeof T0==="object"&&T0!==null&&typeof T0.then==="function"&&(W1.asyncTransitions++,T0.then(o,o),T0.then(W,C5))}catch(Y1){C5(Y1)}finally{Y0===null&&A0._updatedFibers&&(j=A0._updatedFibers.size,A0._updatedFibers.clear(),10{(function(){function Z(){if(y=!1,g){var e=Ca.unstable_now();o=e;var X0=!0;try{Z:{T=!1,C&&(C=!1,S(l),l=-1),R=!0;var G0=v;try{Y:{B(e);for(L=Q(_);L!==null&&!(L.expirationTime>e&&$());){var Z0=L.callback;if(typeof Z0==="function"){L.callback=null,v=L.priorityLevel;var K0=Z0(L.expirationTime<=e);if(e=Ca.unstable_now(),typeof K0==="function"){L.callback=K0,B(e),X0=!0;break Y}L===Q(_)&&z(_),B(e)}else z(_);L=Q(_)}if(L!==null)X0=!0;else{var f=Q(A);f!==null&&U(W,f.startTime-e),X0=!1}}break Z}finally{L=null,v=G0,R=!1}X0=void 0}}finally{X0?q0():g=!1}}}function Y(e,X0){var G0=e.length;e.push(X0);Z:for(;0>>1,K0=e[Z0];if(0>>1;Z0q(i,G0))nq(L0,i)?(e[Z0]=L0,e[n]=G0,Z0=n):(e[Z0]=i,e[z0]=G0,Z0=z0);else if(nq(L0,G0))e[Z0]=L0,e[n]=G0,Z0=n;else break Z}}return X0}function q(e,X0){var G0=e.sortIndex-X0.sortIndex;return G0!==0?G0:e.id-X0.id}function B(e){for(var X0=Q(A);X0!==null;){if(X0.callback===null)z(A);else if(X0.startTime<=e)z(A),X0.sortIndex=X0.expirationTime,Y(_,X0);else break;X0=Q(A)}}function W(e){if(C=!1,B(e),!T)if(Q(_)!==null)T=!0,g||(g=!0,q0());else{var X0=Q(A);X0!==null&&U(W,X0.startTime-e)}}function $(){return y?!0:Ca.unstable_now()-oe||125Z0?(e.sortIndex=G0,Y(A,e),Q(_)===null&&e===Q(A)&&(C?(S(l),l=-1):C=!0,U(W,G0-Z0))):(e.sortIndex=K0,Y(_,e),T||R||(T=!0,g||(g=!0,q0()))),e},Ca.unstable_shouldYield=$,Ca.unstable_wrapCallback=function(e){var X0=v;return function(){var G0=v;v=X0;try{return e.apply(this,arguments)}finally{v=G0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var dI=D3((Da)=>{var dA=h(J0());(function(){function Z(){}function Y(H){return""+H}function Q(H,O,_){var A=3` tag.%s',_),typeof H==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=z(_,O.crossOrigin);$.d.L(H,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Da.preloadModule=function(H,O){var _="";typeof H==="string"&&H||(_+=" The `href` argument encountered was "+q(H)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+q(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+q(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof H==="string"&&(O?(_=z(O.as,O.crossOrigin),$.d.m(H,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(H))},Da.requestFormReset=function(H){$.d.r(H)},Da.unstable_batchedUpdates=function(H,O){return H(O)},Da.useFormState=function(H,O,_){return W().useFormState(H,O,_)},Da.useFormStatus=function(){return W().useHostTransitionStatus()},Da.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var E3=D3((hQ0,lI)=>{var Ea=h(dI());lI.exports=Ea});var rI=D3((ba)=>{var a1=h(cI()),oQ=h(J0()),lA=h(E3());(function(){function Z(J,X){for(J=J.memoizedState;J!==null&&0=X.length)return G;var w=X[K],N=T2(J)?J.slice():b1({},J);return N[w]=Y(J[w],X,K+1,G),N}function Q(J,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GA8?console.error("Unexpected pop."):(X!==V_[A8]&&console.error("Unexpected Fiber popped."),J.current=P_[A8],P_[A8]=null,V_[A8]=null,A8--)}function $0(J,X,K){A8++,P_[A8]=J.current,V_[A8]=K,J.current=X}function w0(J){return J===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),J}function e(J,X){$0(j9,X,J),$0(sq,J,J),$0(I9,null,J);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?vD(X):y8:y8;break;default:if(K=X.tagName,X=X.namespaceURI)X=vD(X),X=CD(X,K);else switch(K){case"svg":X=aQ;break;case"math":X=sG;break;default:X=y8}}K=K.toLowerCase(),K=FT(null,K),K={context:X,ancestorInfo:K},q0(I9,J),$0(I9,K,J)}function X0(J){q0(I9,J),q0(sq,J),q0(j9,J)}function G0(){return w0(I9.current)}function Z0(J){J.memoizedState!==null&&$0(t$,J,J);var X=w0(I9.current),K=J.type,G=CD(X.context,K);K=FT(X.ancestorInfo,K),G={context:G,ancestorInfo:K},X!==G&&($0(sq,J,J),$0(I9,G,J))}function K0(J){sq.current===J&&(q0(I9,J),q0(sq,J)),t$.current===J&&(q0(t$,J),gK._currentValue=EY)}function f(){}function z0(){if(oq===0){QE=console.log,XE=console.info,zE=console.warn,qE=console.error,KE=console.group,BE=console.groupCollapsed,WE=console.groupEnd;var J={configurable:!0,enumerable:!0,value:f,writable:!0};Object.defineProperties(console,{info:J,log:J,warn:J,error:J,group:J,groupCollapsed:J,groupEnd:J})}oq++}function i(){if(oq--,oq===0){var J={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:b1({},J,{value:QE}),info:b1({},J,{value:XE}),warn:b1({},J,{value:zE}),error:b1({},J,{value:qE}),group:b1({},J,{value:KE}),groupCollapsed:b1({},J,{value:BE}),groupEnd:b1({},J,{value:WE})})}0>oq&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function n(J){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,J=J.stack,Error.prepareStackTrace=X,J.startsWith(`Error: react-stack-top-frame +See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),H}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var $={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),M=dA.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),Da.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=$,Da.createPortal=function(H,O){var _=2` tag.%s',_),typeof H==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=z(_,O.crossOrigin);$.d.L(H,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Da.preloadModule=function(H,O){var _="";typeof H==="string"&&H||(_+=" The `href` argument encountered was "+q(H)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+q(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+q(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof H==="string"&&(O?(_=z(O.as,O.crossOrigin),$.d.m(H,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(H))},Da.requestFormReset=function(H){$.d.r(H)},Da.unstable_batchedUpdates=function(H,O){return H(O)},Da.useFormState=function(H,O,_){return W().useFormState(H,O,_)},Da.useFormStatus=function(){return W().useHostTransitionStatus()},Da.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var b3=D3((hQ0,lI)=>{var ba=h(dI());lI.exports=ba});var rI=D3((Ea)=>{var a1=h(cI()),oQ=h(J0()),lA=h(b3());(function(){function Z(J,X){for(J=J.memoizedState;J!==null&&0=X.length)return G;var w=X[K],N=T2(J)?J.slice():E1({},J);return N[w]=Y(J[w],X,K+1,G),N}function Q(J,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GA8?console.error("Unexpected pop."):(X!==V_[A8]&&console.error("Unexpected Fiber popped."),J.current=P_[A8],P_[A8]=null,V_[A8]=null,A8--)}function $0(J,X,K){A8++,P_[A8]=J.current,V_[A8]=K,J.current=X}function w0(J){return J===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),J}function e(J,X){$0(y9,X,J),$0(sq,J,J),$0(I9,null,J);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?vD(X):j8:j8;break;default:if(K=X.tagName,X=X.namespaceURI)X=vD(X),X=CD(X,K);else switch(K){case"svg":X=aQ;break;case"math":X=sG;break;default:X=j8}}K=K.toLowerCase(),K=FT(null,K),K={context:X,ancestorInfo:K},q0(I9,J),$0(I9,K,J)}function X0(J){q0(I9,J),q0(sq,J),q0(y9,J)}function G0(){return w0(I9.current)}function Z0(J){J.memoizedState!==null&&$0(t$,J,J);var X=w0(I9.current),K=J.type,G=CD(X.context,K);K=FT(X.ancestorInfo,K),G={context:G,ancestorInfo:K},X!==G&&($0(sq,J,J),$0(I9,G,J))}function K0(J){sq.current===J&&(q0(I9,J),q0(sq,J)),t$.current===J&&(q0(t$,J),gK._currentValue=bY)}function f(){}function z0(){if(oq===0){Qb=console.log,Xb=console.info,zb=console.warn,qb=console.error,Kb=console.group,Bb=console.groupCollapsed,Wb=console.groupEnd;var J={configurable:!0,enumerable:!0,value:f,writable:!0};Object.defineProperties(console,{info:J,log:J,warn:J,error:J,group:J,groupCollapsed:J,groupEnd:J})}oq++}function i(){if(oq--,oq===0){var J={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:E1({},J,{value:Qb}),info:E1({},J,{value:Xb}),warn:E1({},J,{value:zb}),error:E1({},J,{value:qb}),group:E1({},J,{value:Kb}),groupCollapsed:E1({},J,{value:Bb}),groupEnd:E1({},J,{value:Wb})})}0>oq&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function n(J){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,J=J.stack,Error.prepareStackTrace=X,J.startsWith(`Error: react-stack-top-frame `)&&(J=J.slice(29)),X=J.indexOf(` `),X!==-1&&(J=J.slice(X+1)),X=J.indexOf("react_stack_bottom_frame"),X!==-1&&(X=J.lastIndexOf(` -`,X)),X!==-1)J=J.slice(0,X);else return"";return J}function L0(J){if(L_===void 0)try{throw Error()}catch(K){var X=K.stack.trim().match(/\n( *(at )?)/);L_=X&&X[1]||"",$E=-1)":-1F||b[N]!==a[F]){var s=` -`+b[N].replace(" at new "," at ");return J.displayName&&s.includes("")&&(s=s.replace("",J.displayName)),typeof J==="function"&&T_.set(J,s),s}while(1<=N&&0<=F);break}}}finally{R_=!1,M0.H=G,i(),Error.prepareStackTrace=K}return b=(b=J?J.displayName||J.name:"")?L0(b):"",typeof J==="function"&&T_.set(J,b),b}function H0(J,X){switch(J.tag){case 26:case 27:case 5:return L0(J.type);case 16:return L0("Lazy");case 13:return J.child!==X&&X!==null?L0("Suspense Fallback"):L0("Suspense");case 19:return L0("SuspenseList");case 0:case 15:return x0(J.type,!1);case 11:return x0(J.type.render,!1);case 1:return x0(J.type,!0);case 31:return L0("Activity");default:return""}}function p0(J){try{var X="",K=null;do{X+=H0(J,K);var G=J._debugInfo;if(G)for(var w=G.length-1;0<=w;w--){var N=G[w];if(typeof N.name==="string"){var F=X;Z:{var{name:V,env:D,debugLocation:b}=N;if(b!=null){var a=n(b),s=a.lastIndexOf(` +`);for(F=N=0;NF||E[N]!==a[F]){var s=` +`+E[N].replace(" at new "," at ");return J.displayName&&s.includes("")&&(s=s.replace("",J.displayName)),typeof J==="function"&&T_.set(J,s),s}while(1<=N&&0<=F);break}}}finally{R_=!1,M0.H=G,i(),Error.prepareStackTrace=K}return E=(E=J?J.displayName||J.name:"")?L0(E):"",typeof J==="function"&&T_.set(J,E),E}function H0(J,X){switch(J.tag){case 26:case 27:case 5:return L0(J.type);case 16:return L0("Lazy");case 13:return J.child!==X&&X!==null?L0("Suspense Fallback"):L0("Suspense");case 19:return L0("SuspenseList");case 0:case 15:return x0(J.type,!1);case 11:return x0(J.type.render,!1);case 1:return x0(J.type,!0);case 31:return L0("Activity");default:return""}}function p0(J){try{var X="",K=null;do{X+=H0(J,K);var G=J._debugInfo;if(G)for(var w=G.length-1;0<=w;w--){var N=G[w];if(typeof N.name==="string"){var F=X;Z:{var{name:V,env:D,debugLocation:E}=N;if(E!=null){var a=n(E),s=a.lastIndexOf(` `),c=s===-1?a:a.slice(s+1);if(c.indexOf(V)!==-1){var B0=` `+c;break Z}}B0=L0(V+(D?" ["+D+"]":""))}X=F+B0}}K=J,J=J.return}while(J);return X}catch(v0){return` Error generating stack: `+v0.message+` @@ -30,24 +30,24 @@ Error generating stack: `+v0.message+` `+w)}}else if(J.debugStack!=null){var N=J.debugStack;(J=J.owner)&&N&&(X+=` `+n(N))}else break;var F=X}catch(V){F=` Error generating stack: `+V.message+` -`+V.stack}return F}function R0(J,X,K,G,w,N,F){var V=v4;C1(J);try{return J!==null&&J._debugTask?J._debugTask.run(X.bind(null,K,G,w,N,F)):X(K,G,w,N,F)}finally{C1(V)}throw Error("runWithFiberInDEV should never be called in production. This is a bug in React.")}function C1(J){M0.getCurrentStack=J===null?null:B1,_3=!1,v4=J}function X5(J){return typeof Symbol==="function"&&Symbol.toStringTag&&J[Symbol.toStringTag]||J.constructor.name||"Object"}function n5(J){try{return O5(J),!1}catch(X){return!0}}function O5(J){return""+J}function H1(J,X){if(n5(J))return console.error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function t6(J,X){if(n5(J))return console.error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function W1(J){if(n5(J))return console.error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before using it here.",X5(J)),O5(J)}function N4(J){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{HQ=X.inject(J),F7=X}catch(K){console.error("React instrumentation encountered an error: %o.",K)}return X.checkDCE?!0:!1}function S1(J){if(typeof cl==="function"&&dl(J),F7&&typeof F7.setStrictMode==="function")try{F7.setStrictMode(HQ,J)}catch(X){A3||(A3=!0,console.error("React instrumentation encountered an error: %o",X))}}function V6(J){return J>>>=0,J===0?32:31-(ll(J)/rl|0)|0}function Z7(J){var X=J&42;if(X!==0)return X;switch(J&-J){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return J&261888;case 262144:case 524288:case 1048576:case 2097152:return J&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return J&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),J}}function d4(J,X,K){var G=J.pendingLanes;if(G===0)return 0;var w=0,N=J.suspendedLanes,F=J.pingedLanes;J=J.warmLanes;var V=G&134217727;return V!==0?(G=V&~N,G!==0?w=Z7(G):(F&=V,F!==0?w=Z7(F):K||(K=V&~J,K!==0&&(w=Z7(K))))):(V=G&~N,V!==0?w=Z7(V):F!==0?w=Z7(F):K||(K=G&~J,K!==0&&(w=Z7(K)))),w===0?0:X!==0&&X!==w&&(X&N)===0&&(N=w&-w,K=X&-X,N>=K||N===32&&(K&4194048)!==0)?X:w}function x2(J,X){return(J.pendingLanes&~(J.suspendedLanes&~J.pingedLanes)&X)===0}function L6(J,X){switch(J){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function R6(){var J=ZG;return ZG<<=1,(ZG&62914560)===0&&(ZG=4194304),J}function Y7(J){for(var X=[],K=0;31>K;K++)X.push(J);return X}function C5(J,X){J.pendingLanes|=X,X!==268435456&&(J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0)}function $2(J,X,K,G,w,N){var F=J.pendingLanes;J.pendingLanes=K,J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0,J.expiredLanes&=K,J.entangledLanes&=K,J.errorRecoveryDisabledLanes&=K,J.shellSuspendCounter=0;var{entanglements:V,expirationTimes:D,hiddenUpdates:b}=J;for(K=F&~K;0"u")return null;try{return J.activeElement||J.body}catch(X){return J.body}}function S0(J){return J.replace(tl,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function l0(J,X){X.checked===void 0||X.defaultChecked===void 0||NE||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",R1()||"A component",X.type),NE=!0),X.value===void 0||X.defaultValue===void 0||HE||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",R1()||"A component",X.type),HE=!0)}function a0(J,X,K,G,w,N,F,V){if(J.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(H1(F,"type"),J.type=F):J.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&J.value===""||J.value!=X)J.value=""+t(X)}else J.value!==""+t(X)&&(J.value=""+t(X));else F!=="submit"&&F!=="reset"||J.removeAttribute("value");X!=null?r0(J,F,t(X)):K!=null?r0(J,F,t(K)):G!=null&&J.removeAttribute("value"),w==null&&N!=null&&(J.defaultChecked=!!N),w!=null&&(J.checked=w&&typeof w!=="function"&&typeof w!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(H1(V,"name"),J.name=""+t(V)):J.removeAttribute("name")}function J1(J,X,K,G,w,N,F,V){if(N!=null&&typeof N!=="function"&&typeof N!=="symbol"&&typeof N!=="boolean"&&(H1(N,"type"),J.type=N),X!=null||K!=null){if(!(N!=="submit"&&N!=="reset"||X!==void 0&&X!==null)){P0(J);return}K=K!=null?""+t(K):"",X=X!=null?""+t(X):K,V||X===J.value||(J.value=X),J.defaultValue=X}G=G!=null?G:w,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,J.checked=V?J.checked:!!G,J.defaultChecked=!!G,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(H1(F,"name"),J.name=F),P0(J)}function r0(J,X,K){X==="number"&&y0(J.ownerDocument)===J||J.defaultValue===""+K||(J.defaultValue=""+K)}function p1(J,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?oQ.Children.forEach(X.children,function(K){K==null||typeof K==="string"||typeof K==="number"||typeof K==="bigint"||_E||(_E=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to