mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Merge remote-tracking branch 'origin/main' into fix/auto-pr-resolved-client
This commit is contained in:
commit
b4a5dbe839
178 changed files with 8243 additions and 3239 deletions
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
status: ready
|
||||
priority: p1
|
||||
issue_id: "001"
|
||||
tags: [rust, clippy, async-io, std-fs]
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Several Rust crates still contain `FOLLOW-UP:` markers related to blocking `std::fs` or sync I/O on async paths. The requested work is to execute the implementation plan in `~/.claude/plans/we-ll-feal-with-std-fs-jaunty-feigenbaum.md` and finish the refactors or tighten the remaining sync justifications.
|
||||
|
||||
## Findings
|
||||
|
||||
- The repo is currently on `main`, and the user explicitly approved proceeding there.
|
||||
- `docs/solutions/` is not present, so there are no repo learnings to consult for this task.
|
||||
- The current code matches the plan buckets across `fabro-agent`, `fabro-devcontainer`, `fabro-llm`, and `fabro-workflow`.
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
- Execute the plan in bucket order, using targeted failing checks before each production change where feasible.
|
||||
- Prefer async propagation for truly async paths and `spawn_blocking` only at natural async boundaries.
|
||||
- Remove or narrow `#[expect(clippy::disallowed_methods)]` annotations once the production sites are fixed.
|
||||
|
||||
## Recommended Action
|
||||
|
||||
Implement the plan directly, verify each bucket with crate-level tests or lint checks, then run the final formatting, clippy, workspace tests, and `FOLLOW-UP` sweep.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- All `FOLLOW-UP:` markers under `lib/crates/` are removed.
|
||||
- The planned async refactors and `spawn_blocking` boundary changes are implemented.
|
||||
- Formatting and workspace clippy pass.
|
||||
- Relevant crate tests pass during incremental verification.
|
||||
|
||||
## Work Log
|
||||
|
||||
- 2026-04-19: Created execution todo, confirmed branch choice with the user, and started inspecting the planned call sites.
|
||||
|
||||
|
|
@ -100,6 +100,7 @@ When working on Rust crates, read the relevant strategy doc **before** making ch
|
|||
- **`docs-internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable
|
||||
- **`docs-internal/events-strategy.md`** — read when adding or modifying `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
|
||||
- **`files-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures
|
||||
- **`docs-internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing
|
||||
|
||||
## Shell quoting in sandbox code
|
||||
|
||||
|
|
|
|||
77
Cargo.lock
generated
77
Cargo.lock
generated
|
|
@ -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,7 +1541,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1561,7 +1561,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1582,7 +1582,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-store",
|
||||
|
|
@ -1597,7 +1597,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1687,7 +1687,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1705,6 +1705,7 @@ dependencies = [
|
|||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -1714,7 +1715,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1737,7 +1738,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -1752,7 +1753,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-devcontainer"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-util",
|
||||
|
|
@ -1768,7 +1769,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
|
|
@ -1784,7 +1785,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -1798,7 +1799,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -1821,7 +1822,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"http",
|
||||
"reqwest 0.13.2",
|
||||
|
|
@ -1830,7 +1831,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1844,7 +1845,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -1858,7 +1859,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1888,7 +1889,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1897,7 +1898,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-config",
|
||||
|
|
@ -1913,7 +1914,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"insta",
|
||||
"serde",
|
||||
|
|
@ -1923,7 +1924,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -1942,7 +1943,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -1951,7 +1952,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-retro"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1969,7 +1970,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2001,7 +2002,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2079,7 +2080,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -2098,14 +2099,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",
|
||||
|
|
@ -2131,7 +2132,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2156,7 +2157,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-util",
|
||||
|
|
@ -2168,7 +2169,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
|
|
@ -2190,7 +2191,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-github",
|
||||
|
|
@ -2203,7 +2204,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -2224,7 +2225,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
|
|
@ -2246,7 +2247,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-graphviz",
|
||||
"fabro-model",
|
||||
|
|
@ -2256,7 +2257,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
|
@ -2267,7 +2268,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -6912,7 +6913,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -6931,7 +6932,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.211.0-nightly.1"
|
||||
version = "0.212.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ cd "$(dirname "$0")/../.."
|
|||
|
||||
server_symbol_allowlist=(
|
||||
"lib/crates/fabro-cli/src/local_server.rs"
|
||||
"lib/crates/fabro-cli/src/commands/install.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"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ disallowed-methods = [
|
|||
{ path = "std::fs::File::create", reason = "Blocking open; prefer tokio::fs::File::create on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "std::fs::File::create_new", reason = "Blocking open; prefer tokio::fs::File::create_new on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "std::fs::OpenOptions::open", reason = "Blocking open; prefer tokio::fs::OpenOptions::open on Tokio paths. OS file-lock semantics may require spawn_blocking instead. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "std::env::set_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
|
||||
{ path = "std::env::remove_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
|
||||
{ path = "reqwest::Client::new", reason = "Use fabro_http::http_client() or fabro_http::test_http_client()", allow-invalid = true },
|
||||
{ path = "reqwest::Client::builder", reason = "Use fabro_http::HttpClientBuilder::new()", allow-invalid = true },
|
||||
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
docs-internal/server-secrets-strategy.md
Normal file
69
docs-internal/server-secrets-strategy.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Server Secrets Strategy
|
||||
|
||||
This document defines how Fabro handles server-level secrets.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `ServerSecrets` is the canonical server-secret reader.
|
||||
- It reads from `process env` and `<storage>/server.env`.
|
||||
- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
|
||||
- `process env` wins over `server.env` on conflicts.
|
||||
- `fabro server start` never generates secrets. Missing required secrets are a startup error.
|
||||
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. Enforced by clippy via `disallowed_methods` in `clippy.toml`; intentional exceptions must be annotated with a scoped `#[expect(clippy::disallowed_methods, reason = "...")]` at the call site.
|
||||
|
||||
## Active Server Secrets
|
||||
|
||||
These values belong to the server runtime and are read via `state.server_secret(...)`:
|
||||
|
||||
| Secret | Used by |
|
||||
|---|---|
|
||||
| `SESSION_SECRET` | Cookie encryption and JWT signing derivation |
|
||||
| `FABRO_DEV_TOKEN` | Dev-token user auth when `server.auth.methods` includes `dev-token` |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | GitHub App credentials |
|
||||
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification |
|
||||
| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth login |
|
||||
|
||||
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
|
||||
|
||||
## Startup
|
||||
|
||||
- Foreground and daemon startup use the same validation path.
|
||||
- Required-at-startup secrets are:
|
||||
- `SESSION_SECRET`
|
||||
- `FABRO_DEV_TOKEN` when dev-token auth is enabled
|
||||
- `GITHUB_APP_CLIENT_SECRET` when GitHub auth is enabled
|
||||
- Other server secrets remain lazy/feature-specific rather than universal boot blockers.
|
||||
|
||||
## Provisioning
|
||||
|
||||
Server secrets come from one of two sources:
|
||||
|
||||
- Platform env for 12-factor deployments
|
||||
- `server.env` written by install flows
|
||||
|
||||
There is no compatibility layer for removed secrets and no startup-time secret generation.
|
||||
|
||||
## Subprocess Boundaries
|
||||
|
||||
- Worker and render-graph subprocesses start from `env_clear()` and re-add only explicit allowlisted variables.
|
||||
- Authority-bearing values are re-injected intentionally. For worker subprocesses this is `FABRO_WORKER_TOKEN`, not user auth state such as `FABRO_DEV_TOKEN` or `auth.json`.
|
||||
- The worker reads `FABRO_WORKER_TOKEN` from its env at startup (in `main()` before Tokio initializes) and immediately calls `std::env::remove_var` to scrub it. The token then flows through function arguments to `runner::execute`. Every descendant process (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it.
|
||||
- The daemon child inherits the parent env unchanged except for output-format hygiene (`FABRO_JSON` removal).
|
||||
|
||||
## Tests
|
||||
|
||||
- In-process tests must inject server secrets with construction-time stubs (`EnvSource`, `StubEnv`) or by writing `server.env`.
|
||||
- Subprocess tests must set child env with `Command::env`.
|
||||
- Tests must not mutate the process-wide environment.
|
||||
|
||||
## Rotation
|
||||
|
||||
- Secret rotation requires restart.
|
||||
- Live rotation is intentionally unsupported.
|
||||
|
||||
## Adding A New Server Secret
|
||||
|
||||
1. Provision it through platform env or install-written `server.env`.
|
||||
2. Read it through `state.server_secret(...)`.
|
||||
3. Decide explicitly whether startup should fail when it is absent.
|
||||
4. If a worker or render subprocess needs it, re-inject it explicitly rather than broadening inheritance casually.
|
||||
|
|
@ -30,7 +30,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
|
|||
|
||||
- **Enable authentication.** Fabro supports `dev-token` and GitHub OAuth. Do not disable auth outside of local development or controlled demos.
|
||||
- **Configure a username allowlist for GitHub OAuth.** `[server.auth.github].allowed_usernames` should contain the exact GitHub users allowed to log in. An empty list rejects everyone.
|
||||
- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. If you also provision `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY`, treat them as server runtime secrets, but they are not what currently gates browser auth.
|
||||
- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments.
|
||||
- **Terminate HTTPS or mTLS upstream when needed.** Fabro's listener is plain HTTP/Unix only. If CI, scripts, or a browser must connect over HTTPS, terminate TLS at a reverse proxy or load balancer and keep the Fabro listener on a private network.
|
||||
|
||||
### Secrets
|
||||
|
|
|
|||
|
|
@ -299,7 +299,6 @@ For the auth model above, the main server runtime secrets are:
|
|||
- `SESSION_SECRET` when the web UI is enabled
|
||||
- `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled
|
||||
- `GITHUB_APP_CLIENT_SECRET` when `"github"` auth is enabled
|
||||
- `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, provisioned during install for future CLI login flows
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when the install wizard or a manual config uses
|
||||
static S3 object-store credentials
|
||||
|
||||
|
|
@ -332,8 +331,6 @@ Fabro resolves these from `process env -> server.env`.
|
|||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `FABRO_JWT_PRIVATE_KEY` | Ed25519 private key (base64-encoded PEM) for JWT signing |
|
||||
| `FABRO_JWT_PUBLIC_KEY` | Ed25519 public key (base64-encoded PEM) for JWT verification |
|
||||
| `SESSION_SECRET` | Session encryption secret (64-character hex string) |
|
||||
|
||||
### Object store runtime secrets (optional)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -47,5 +47,4 @@ If something is misconfigured, `fabro doctor` tells you exactly what's wrong and
|
|||
- New indicatif-based progress display for `fabro run start` shows real-time stage progress, tool calls, model names, and timing
|
||||
- Mercury provider updated to `mercury-2`; estimated output speed (tok/s) added to `fabro model list`
|
||||
- Run defaults in `server.toml` are inherited by all workflow runs, so you don't have to repeat sandbox, model, or concurrency settings
|
||||
- `FABRO_JWT_PUBLIC_KEY` and `FABRO_JWT_PRIVATE_KEY` accept base64-encoded PEM strings for containerized deployments
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -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 <run-id>
|
||||
fabro dump <run-id>
|
||||
```
|
||||
|
||||
## More
|
||||
|
|
|
|||
|
|
@ -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 <RUN>` | Full event envelope stream as NDJSON |
|
||||
| `fabro inspect <RUN>` | Current durable run state, including run/start/checkpoint/conclusion records |
|
||||
| `fabro store dump --output <DIR> <RUN>` | Exported `events.jsonl` plus reconstructed JSON and node files |
|
||||
| `fabro dump --output <DIR> <RUN>` | 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
---
|
||||
title: "refactor: Lock down server-level secrets handling"
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
deepened: 2026-04-23
|
||||
---
|
||||
|
||||
# Lock down server-level secrets handling
|
||||
|
||||
## Overview
|
||||
|
||||
Server-level secrets (SESSION_SECRET, FABRO_DEV_TOKEN, GitHub App credentials, JWT keypair) flow through three independent paths today:
|
||||
|
||||
- `ServerSecrets::get` reads process env *live* on every call, with on-disk envfile fallback.
|
||||
- `load_or_create_local_session_secret` reads env first, then envfile, then auto-generates if missing.
|
||||
- `execute_foreground` mutates parent process env via `std::env::set_var` so the in-process server's live env reads see the resolved value.
|
||||
|
||||
Worker subprocess scrubs `FABRO_DEV_TOKEN`; daemon spawn does not — accidental inconsistency.
|
||||
|
||||
This refactor:
|
||||
|
||||
- **Makes `ServerSecrets` snapshot-based.** Reads env and envfile *once* at construction, exposes the merged result. Both env and file remain valid sources (env wins on conflict — 12-factor convention). The bug was *live* env reads coupled with parent-process mutation, not env reads themselves.
|
||||
- **Eliminates parent-process env mutation.** With snapshot semantics, the foreground `set_var` becomes unnecessary; the daemon `cmd.env(SESSION_SECRET)` becomes redundant.
|
||||
- **CLI install and web install share a common orchestration path for `server.env` writes.** Coordinated install flows may update server-level secrets while a server is running, but they must also own restart/handoff (web install already does this via `/install/finish`).
|
||||
- **Worker and render-graph subprocesses use `env_clear` + strict fail-closed allowlists.** Worker is the trust boundary — it dispatches user-supplied workflow stages via `Sandbox`. Daemon child inherits parent env unchanged (12-factor pattern works).
|
||||
- **Foreground and daemon startup share one validation path.** Daemon preflight builds the same `ServerSecrets` snapshot and runs the same server-side auth/startup validation foreground does — no separate parent-CLI validator with drift risk.
|
||||
- **Legacy `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` drift is removed.** SESSION_SECRET remains the sole auth master (HKDF source for cookie key + JWT signing key per `2026-04-19-003-feat-cli-auth-login-plan.md`); install stops generating those keys and actively removes them from `server.env` on subsequent runs.
|
||||
- **`clippy::disallowed_methods` denies `std::env::set_var` / `remove_var`** workspace-wide *including tests*; narrow documented post-fork/pre-exec exceptions only.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
- **Live env reads + parent-process mutation is unsound.** `std::env::set_var` in `execute_foreground` mutates global process state inside an async runtime. Rust 2024 marks this `unsafe` because it races concurrent readers. Workspace is on 2021; moving to 2024 forces the issue. The set_var is load-bearing because `ServerSecrets::get` reads env *live*, not at construction.
|
||||
- **Worker subprocess env leakage.** Workers run user-supplied workflow stages (via `Sandbox`). Today they inherit the operator's full process env — any credential the operator exported leaks to user-controlled commands.
|
||||
- **`SESSION_SECRET` autogeneration is the real startup bypass.** `load_or_create_local_session_secret` mints a value and writes the envfile if missing — a server can boot with a freshly-minted secret that bypassed the install flow's full setup. Same precedent already removed for `FABRO_DEV_TOKEN` (commit `7cb6c65d5`); SESSION_SECRET is the leftover.
|
||||
- **`FABRO_JWT_*` is stale config drift.** The CLI auth migration (`2026-04-19-003-feat-cli-auth-login-plan.md`) consolidated cookie + JWT signing into a single HKDF chain rooted at SESSION_SECRET. The legacy `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` envfile entries no longer participate in the runtime auth model but install still generates them and diagnostics still inspects them — they obscure the actual auth model and risk operator confusion.
|
||||
- **CLI install and web install have drifted into separate persistence/restart behaviors.** They write to `server.env` via parallel code paths today; the bug-surface of "what does install actually persist" is twice as large as it should be. They should reconverge on a shared orchestration with consistent contracts.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. `ServerSecrets` reads env and envfile *once* at boot and exposes the merged snapshot. No live env reads from `ServerSecrets::get` or its callers on the resolution path. Env wins on conflict.
|
||||
- R2. `fabro server start` does not generate any server-level secret. Missing → fail fast pointing at the install flows (CLI or web) or direct env-set.
|
||||
- R3. `execute_foreground` does not mutate parent process env.
|
||||
- R4. `execute_daemon` does not pass server-level secrets via `cmd.env(...)` (no longer needed; daemon child reads env+file at its own boot).
|
||||
- R5. Worker and render-graph subprocesses use `env_clear` + strict fail-closed allowlists. New inherited env vars require demonstrated need plus tests. Authority-bearing values re-injected explicitly.
|
||||
- R6. Tests use construction-time env stubs (via a small `EnvSource` trait) or explicit child-process `Command::env`. No test mutates process-wide env.
|
||||
- R7. Foreground and daemon startup use one shared validation path; required secrets mirror current auth rules (SESSION_SECRET always; FABRO_DEV_TOKEN when dev-token auth enabled; GITHUB_APP_CLIENT_SECRET when GitHub auth enabled).
|
||||
- R8. CLI install and web install share orchestration for coordinated `server.env` writes and restart/handoff.
|
||||
- R9. `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` are removed from install output, diagnostics, docs, and existing `server.env` files during install flows.
|
||||
- R10. Behavior changes are documented in a strategy doc and enforced via workspace-wide `clippy::disallowed_methods` — including tests — with narrow documented exceptions only.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope, active** — server-level secrets read via `state.server_secret(...)`, sourced from process env or `<storage>/server.env`:
|
||||
|
||||
| Secret | Consumer | Sources |
|
||||
|---|---|---|
|
||||
| `SESSION_SECRET` | `AppState::session_key` (`server.rs:713`); HKDF source for cookie key + JWT signing key | env (12-factor) or `server.env` (install) |
|
||||
| `FABRO_DEV_TOKEN` | `worker_command` (`server.rs:3825`) | env or `server.env` |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | `AppState::github_credentials` (`server.rs:726`) | env or `server.env` |
|
||||
| `GITHUB_APP_WEBHOOK_SECRET` | `github_webhook_routes` (`server.rs:983`) | env or `server.env` |
|
||||
| `GITHUB_APP_CLIENT_SECRET` | `web_auth.rs:536` | env or `server.env` |
|
||||
|
||||
Both install flows (CLI `fabro install` and web install via `/install/finish`) write the envfile; neither mutates process env. Operators on 12-factor PaaS set secrets in platform env; operators on local/server installs use one of the install flows.
|
||||
|
||||
**In scope, removal targets:**
|
||||
|
||||
- `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY` — legacy entries that no longer participate in the runtime auth model. Install stops generating them; diagnostics stops inspecting them; existing entries are actively removed from `server.env` on subsequent install flows.
|
||||
|
||||
**Startup-critical subset:** SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth is enabled), and GITHUB_APP_CLIENT_SECRET (when GitHub auth is enabled). The other in-scope active secrets (GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET) are not boot blockers — they continue to surface where they did before (conditional route mounts, per-request errors). The plan does not promote every server secret to a startup gate.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- **Vault + `ProviderCredentials`** (`secrets.json`, REST-managed, runtime-mutable). Different lifecycle.
|
||||
- **`vault_or_env` for run-level credentials** (`GITHUB_TOKEN`, `DAYTONA_API_KEY`). Same env-as-source pattern, different track.
|
||||
- **`{{ env.FOO }}` config interpolation.** Operator-supplied templating, different feature.
|
||||
- **Workflow-stage env (Sandbox).** Stages run inside `Sandbox` (local/Docker/Daytona); their env is configured by the workflow definition + Sandbox config. Anything a workflow stage needs to execute (e.g. `git push` requiring `GITHUB_TOKEN`) routes via Vault → Sandbox, not subprocess inheritance.
|
||||
- **`validate_api_key` `set_var` in `provider_auth.rs`.** Vault-side smell, deferred.
|
||||
- **Tailscale and `bun --watch-web` spawns.** Different surfaces.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant code
|
||||
|
||||
- `lib/crates/fabro-server/src/server_secrets.rs` — `ServerSecrets` definition; today's live `env_lookup` closure is the bug.
|
||||
- `lib/crates/fabro-server/src/server.rs:697-699` — `state.server_secret(name)` wrapper.
|
||||
- `lib/crates/fabro-server/src/server.rs:703,712-715` and `jwt_auth.rs:84-99` — `resolve_auth_mode_with_lookup`, currently routed through the live `env_lookup`.
|
||||
- `lib/crates/fabro-server/src/server.rs:3776-3834` (`worker_command`) — worker spawn site.
|
||||
- `lib/crates/fabro-server/src/server.rs:7232-7235` — `__render-graph` spawn site.
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs:229-274` — `load_or_create_local_session_secret` and `execute_foreground` `set_var` block.
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs:319-362` — `execute_daemon` spawn flow.
|
||||
- `lib/crates/fabro-cli/src/commands/install.rs:1816,1856,1864` — install SESSION_SECRET generation and persistence.
|
||||
- `lib/crates/fabro-config/src/envfile.rs:204-256` — `write_env_entries`, already atomic via tmp + fsync + rename.
|
||||
- `Cargo.toml:111` and `clippy.toml` — existing `disallowed_methods` mechanism.
|
||||
|
||||
### Institutional learnings
|
||||
|
||||
- `docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md` — architectural anchor: server is canonical, install provisions, request-time reads from store.
|
||||
- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` — `SESSION_SECRET` is now HKDF master for JWT signing + cookie key. Higher stakes.
|
||||
- `docs/plans/2026-04-18-001-feat-webhook-strategy-plan.md` — webhook secret already plumbed via `AppState` from the resolved store.
|
||||
- Commit `7cb6c65d5` — "Remove dev-token minting from server start." SESSION_SECRET autogenerate is the leftover.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **`ServerSecrets` is a snapshot, not a live reader.** A small `EnvSource` trait exists *only at construction time* and immediately materializes into an owned `HashMap<String, String>`. No trait object or callback survives into runtime lookup. `get(name)` returns from the merged (env ∪ file) snapshot; env wins on conflict (12-factor). Role: the *resolved-secrets API*; the source mix is the operator's choice.
|
||||
- **Production uses a real process-env `EnvSource`; tests use a map-backed stub.** No live env reads anywhere on the secret resolution path. No `EnvLookup` closure surviving into runtime.
|
||||
- **`resolve_auth_mode_with_lookup` migrates to read from `server_secrets`, not raw env.** Closure passed in delegates to `self.server_secrets.get(...)`. Without this, auth-mode resolution remains a live env reader.
|
||||
- **Worker and render-graph allowlists are strict fail-closed.** Worker is the trust boundary (dispatches user stages via `Sandbox`); render-graph is hygiene. New inherited env vars require demonstrated need (failing test) and intentional addition. Proxy/cert env vars are not auto-inherited.
|
||||
- Worker list: `PATH`, `HOME`, `TMPDIR`, `USER`, `RUST_LOG`, `RUST_BACKTRACE`, `FABRO_HOME`, `FABRO_STORAGE_ROOT`. Plus explicit `FABRO_DEV_TOKEN` re-injection when dev-token auth is enabled.
|
||||
- Render-graph list: `PATH`, `HOME`, `TMPDIR`. Plus explicit `FABRO_TELEMETRY=off`.
|
||||
- **Daemon child spawn inherits parent env unchanged** (modulo existing `cmd.env_remove("FABRO_JSON")` for output-format hygiene). The daemon is fabro's own server code, not a security boundary against itself. 12-factor env vars (SESSION_SECRET, AWS_*, RAILWAY_*) flow through naturally to the daemon child's snapshot.
|
||||
- **Daemon preflight reuses the same shared auth/startup validation as foreground startup.** Both modes construct the same `ServerSecrets` snapshot and call the same server-side validation logic (today's `jwt_auth::resolve_auth_mode_with_lookup` already encodes "what's required for this auth mode" — that's the shared path). No separate parent-CLI validator with drift risk. Required secrets remain exactly the current startup-critical set: SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth enabled), GITHUB_APP_CLIENT_SECRET (when GitHub auth enabled). GITHUB_APP_PRIVATE_KEY and GITHUB_APP_WEBHOOK_SECRET remain in-scope server secrets but are not new universal boot blockers.
|
||||
- **Install coordination policy lives in shared install orchestration, not in low-level envfile helpers.** Coordinated install flows (CLI and web) may write to `server.env` while a server is running — but only when they also own restart/handoff. Web install already does this via `/install/finish`. CLI install joins the same orchestration. Manual edits to `server.env` outside the orchestration still require operator restart discipline.
|
||||
- **`pub(crate) server_secrets` field tightened to `pub(super)`.** No production code legitimately bypasses the wrapper.
|
||||
- **Workspace-wide clippy ban including tests.** Add `std::env::set_var`/`remove_var` to `clippy.toml` `disallowed-methods` — applies to production AND test code. Tests must use construction-time `EnvSource` stubs or explicit child-process `Command::env`. Narrow documented exceptions only (`fabro-telemetry/src/spawn.rs:73-76` post-fork pre-execvp).
|
||||
- **Legacy `FABRO_JWT_*` is removed, not preserved.** Install stops generating; diagnostics stops reading; existing envfile entries are actively cleaned up on subsequent install flows. SESSION_SECRET is the sole auth master.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved during planning
|
||||
|
||||
- **`ServerSecrets` reads env AND file, snapshots, env wins.** Process env is a legitimate source (12-factor). The bug was *live* env reads + parent mutation, not env-as-source.
|
||||
- **`EnvSource` exists only at construction time.** A small trait used to materialize an owned `HashMap` once; no callback or trait object survives into runtime lookup. Production uses a real process-env source; tests use a map-backed stub.
|
||||
- **Daemon child inherits parent env; only worker/render-graph scrub.** Daemon is fabro's own server code and must see whatever the operator/platform set in env. Worker is the trust boundary (dispatches user stages via `Sandbox`).
|
||||
- **No carve-out for AWS / cloud-platform credentials.** Daemon inherits parent env, so AWS env names reach the object-store layer naturally.
|
||||
- **Active scope is 5 server secrets plus 2 legacy removal targets.** SESSION_SECRET, FABRO_DEV_TOKEN, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_SECRET are active. FABRO_JWT_PRIVATE_KEY and FABRO_JWT_PUBLIC_KEY are legacy — install stops generating them; diagnostics stops reading them; existing entries are actively removed.
|
||||
- **Workflow stage env is the Sandbox's job.** This plan does not address workflow `bash`/`git`/etc. stage env — that's per-workflow Sandbox config. Workflows requiring credentials route them through Vault.
|
||||
- **Web install keeps its existing restart-handoff contract.** `/install/finish` still returns the restart URL and the SPA still waits for the server to come back. No new `restart_required` flag, no manual-restart message proposal, no API surface change.
|
||||
- **CLI and web install share orchestration for coordinated writes while running.** Blanket "refuse-while-running" is out. The orchestration is responsible for restart/handoff; both flows call into it.
|
||||
- **Test migration shape:** full migration in Unit 1. `ServerSecrets::with_env_lookup` is deleted; tests use either `ServerSecrets::load(path, env_source)` with a map-backed `EnvSource` stub, or `provision_server_secrets(env_path, &[(name,value)])` helper (file-based injection). No `#[cfg(test)]` backdoor.
|
||||
- **Visibility tightening:** `pub(crate) server_secrets` at `server.rs:579` → `pub(super)` as part of Unit 1.
|
||||
- **Install lock window for interactive flows:** install orchestration's job; `gather inputs → persist + restart-handoff`. Serialization window is sub-second regardless of OAuth duration.
|
||||
- **Dev-loop friction:** no `--quick` flag. Contributors run `fabro install` or set secrets directly via env (`SESSION_SECRET=$(openssl rand -hex 32) cargo run -- server start` works under the env-as-source model).
|
||||
- **Compliance-driven rotation:** deferred. Captured as a known limitation in the strategy doc.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: `ServerSecrets` becomes a snapshot built from `EnvSource` (construction-time only)**
|
||||
|
||||
**Goal:** `ServerSecrets` reads env and envfile *once* at construction via a small `EnvSource` trait, materializes both into owned `HashMap`s, and exposes the merged snapshot. No trait object or closure survives into runtime lookup. Auth-mode resolution stops being a live env reader.
|
||||
|
||||
**Requirements:** R1, R6
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/server_secrets.rs` — replace `env_lookup` closure field with `env_entries: HashMap<String, String>` (owned). Introduce a small `EnvSource` trait used *only* at construction:
|
||||
- `pub trait EnvSource { fn snapshot(&self) -> HashMap<String, String>; }`
|
||||
- `pub struct ProcessEnv;` impls `snapshot` via `std::env::vars().collect()`.
|
||||
- `pub struct StubEnv(pub HashMap<String, String>);` impls `snapshot` by cloning. (cfg-test or cfg(any(test, feature = "test-support")) — implementer picks based on existing patterns.)
|
||||
- `pub fn load(path: PathBuf, env: &dyn EnvSource) -> Result<Self, Error>` — calls `env.snapshot()` once, stores the result, never references `env` again.
|
||||
- `get(name)` returns `self.env_entries.get(name).cloned().or_else(|| self.file_entries.get(name).cloned())`.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` — `build_app_state` accepts pre-built `ServerSecrets` and `AuthMode` from the caller (no longer constructs `ServerSecrets` itself). The single production caller is `serve_command` (via `resolve_startup` from Unit 4); test helpers construct both in the same way. Tighten `pub(crate) server_secrets` field at `server.rs:579` to `pub(super)`. Migrate the `resolve_auth_mode_with_lookup` call (`server.rs:703`) to pass a closure delegating to `self.server_secrets.get(...)`.
|
||||
- Modify: `lib/crates/fabro-server/src/serve.rs:512` and `install.rs:910,965,2108` — minimal API migration: `ServerSecrets::load(path, &ProcessEnv)` instead of `with_env_lookup` at any *non-startup* construction sites (e.g. install-time inspection, install_object_store_lookup test helper). The startup construction sites at `serve.rs:594-607` are NOT modified by Unit 1 — Unit 4 collapses them into a single `resolve_startup(...)` call. To keep the workspace compiling between Unit 1 and Unit 4, Unit 1 may leave a temporary `ServerSecrets::load(path, &ProcessEnv)` call at `serve.rs:594` if needed; Unit 4 deletes it. Sequencing is documented in Unit 4's Dependencies.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs:7864-7885` — replace `server_secrets_resolve_process_env_before_server_env` with a snapshot test using `StubEnv` (assert env-wins-on-conflict from the snapshot).
|
||||
- Modify: `lib/crates/fabro-server/tests/it/api/cli_auth_token.rs:34`, `routing.rs:21,35-36`, `tcp.rs:28,68,173-174`, `lib/crates/fabro-cli/tests/it/support/auth_harness.rs:39-86` — migrate from `env_lookup` injection (for *secret* values) to constructing the test's `ServerSecrets` with `&StubEnv(...)`, or writing to a temp `server.env` via the `provision_server_secrets(env_path, &[(name, value)])` helper. Tests that currently pass `env_lookup` only to feed secret values into `ServerSecrets` switch to the new mechanism.
|
||||
- Modify: test helpers like `create_app_state_with_env_lookup` (`server.rs:2488` and similar) — once `build_app_state` (`server.rs:2631`) requires precomputed `ServerSecrets` and `AuthMode`, the helpers must produce both internally and pass them through. The `env_lookup` parameter on these helpers is *retained* (still consumed downstream by `resolve_canonical_origin` at `server.rs:708` and slack `{{ env.FOO }}` interpolation at `server.rs:2676`, both out of scope). New shape: helper accepts an additional `&dyn EnvSource` parameter (or constructs a `StubEnv`/`ProcessEnv` based on the call site's intent), internally calls `resolve_startup(...)` (or directly constructs `ServerSecrets` + computes `AuthMode` if the helper bypasses settings — auditor's choice per call site), and passes the resulting `ServerSecrets` and `AuthMode` into `build_app_state`. Tests that set up specific auth modes (e.g. dev-token enabled) now thread settings + `EnvSource` through the helper rather than relying on a single closure to drive both secret resolution and auth-mode computation.
|
||||
- Test: existing files plus new snapshot-semantics tests in `server_secrets.rs`.
|
||||
|
||||
**Approach:**
|
||||
- `EnvSource::snapshot()` is called exactly once during `ServerSecrets::load`. The trait reference is dropped immediately after; only the materialized `HashMap` is retained. No risk of live env reads via trait object dispatch.
|
||||
- Object-store credential resolution at `serve.rs:340-401, 520-522, 540-542` uses `std::env::var(...)` directly (daemon child inherits parent env, so AWS names are present).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `ServerSecrets::load(path, &StubEnv([("SESSION_SECRET", "from-env")].into()))` returns "from-env" even when file has a different value.
|
||||
- Edge: env empty, file has the value → file wins (single fallback path).
|
||||
- Edge: neither env nor file has the value → `get` returns `None`.
|
||||
- Edge: env has it, file does too, different values → env wins (12-factor).
|
||||
- Edge: missing file path → empty `file_entries`, env-only resolution.
|
||||
- Snapshot semantics: after construction, mutating the source `EnvSource` (or process env, if production source) does not affect `get` returns. The snapshot is owned and immutable.
|
||||
- Auth-mode happy path: `resolve_auth_mode_with_lookup` resolves correctly when secrets come from env, file, or both.
|
||||
- Migration: `cli_auth_token`, `routing`, `tcp`, `auth_harness` tests pass after migration to `EnvSource` stubs.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-server -p fabro-cli` passes.
|
||||
- `grep -rn 'std::env::var\|std::env::vars' lib/crates/fabro-server/src/ | grep -v 'serve.rs\|object_store\|spawn_env\|server_secrets.rs'` shows no live env reads on the server-secret resolution path (the only `std::env::vars()` is inside `ProcessEnv::snapshot`, called once at construction).
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Drop foreground `set_var`; drop daemon `cmd.env(SESSION_SECRET)`**
|
||||
|
||||
**Goal:** Eliminate parent-process env mutation. Daemon parent stops passing SESSION_SECRET via `cmd.env`; daemon child reads env+file at its own boot.
|
||||
|
||||
**Requirements:** R3, R4
|
||||
|
||||
**Dependencies:** Unit 1.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:265-274` — delete the `set_var`/scopeguard block in `execute_foreground`.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:350,352` — `execute_daemon` removes `cmd.env("SESSION_SECRET", ...)`. Keep existing `cmd.env_remove("FABRO_JSON")` (output-format hygiene).
|
||||
- Modify: `lib/crates/fabro-test/src/lib.rs:931` — drop `cmd.env("SESSION_SECRET", ...)` for spawned test servers; tests provision via `server.env` or by setting env on the spawned `Command` explicitly when the test simulates a 12-factor PaaS scenario.
|
||||
- Test: existing `cmd/server_start.rs` tests cover both modes.
|
||||
|
||||
**Approach:**
|
||||
- Unit 1's snapshot semantics make both `set_var` and `cmd.env(SESSION_SECRET)` unnecessary. The in-process foreground server's `ServerSecrets::load(path, &ProcessEnv)` snapshots whatever env the parent CLI had. The daemon child inherits parent env (today's behavior — unchanged) and snapshots it at its own boot.
|
||||
- A `SESSION_SECRET` exported in the operator's parent shell does flow through to both modes — that's the 12-factor design intent.
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
Tests proving env behavior must use construction-time `EnvSource` stubs or explicit child-process `Command::env` — *not* `std::env::set_var`. Any old test using process-env mutation is migrated as part of this unit (or Unit 7's clippy enforcement will fail it).
|
||||
|
||||
- Happy path (foreground, env source): in-process server constructed with `&StubEnv([("SESSION_SECRET", "from-env")].into())`; running server uses that value.
|
||||
- Happy path (foreground, file source): empty `EnvSource`, value in `server.env`; running server uses the file value.
|
||||
- Happy path (foreground, env-wins): env and file have different values; env wins.
|
||||
- Happy path (daemon, env source): test passes `SESSION_SECRET` to spawned daemon via explicit `Command::env`; daemon child inherits and snapshots it. NOT via `std::env::set_var` in the test process.
|
||||
- Happy path (daemon, file source): no env on the spawned `Command`, value in `server.env`; daemon child uses the file value.
|
||||
|
||||
**Verification:**
|
||||
- `grep -rn 'std::env::set_var\|remove_var' lib/crates/fabro-cli/src/` returns no hits in production code.
|
||||
- `grep -rn 'cmd\.env."SESSION_SECRET"' lib/crates/fabro-cli/src/` returns no hits in production code (test code may still use `Command::env` for daemon spawn simulation; that's fine — it's setting child env, not parent env).
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: Worker and render-graph spawns use `env_clear` + strict fail-closed allowlists**
|
||||
|
||||
**Goal:** Worker and render-graph subprocesses inherit only an explicit allowlist. The lists are strict fail-closed — new env vars require demonstrated need (a failing test that proves a workflow execution path needs them) plus intentional addition. Authority-bearing values re-injected explicitly. Daemon child spawn is unchanged (inherits parent env per 12-factor).
|
||||
|
||||
Proxy and TLS env vars (`HTTPS_PROXY`, `SSL_CERT_FILE`, etc.) are *not* inherited unless usage-proven by a failing test and intentionally added — they were never part of the worker's documented contract.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-server/src/spawn_env.rs` — defines two helpers:
|
||||
- `apply_worker_env(&mut tokio::process::Command)` — `env_clear` + worker list.
|
||||
- `apply_render_graph_env(&mut tokio::process::Command)` — `env_clear` + render-graph list.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs:3776-3834` — `worker_command` calls `apply_worker_env(&mut cmd)` first, then existing `cmd.env("FABRO_DEV_TOKEN", token)` re-injection. Remove existing `env_remove("FABRO_JSON")` and `env_remove("FABRO_DEV_TOKEN")` (covered by `env_clear`).
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs:7232-7235` — `__render-graph` spawn calls `apply_render_graph_env(&mut cmd)`. Keep explicit `cmd.env("FABRO_TELEMETRY", "off")`.
|
||||
- Test: new tests in `spawn_env.rs`.
|
||||
|
||||
**Approach:**
|
||||
|
||||
```text
|
||||
WORKER list (each entry has // reason: ... in the source):
|
||||
PATH, HOME, TMPDIR, USER // process essentials
|
||||
RUST_LOG, RUST_BACKTRACE // diagnostics
|
||||
FABRO_HOME, FABRO_STORAGE_ROOT // worker reads its own state
|
||||
+ explicit cmd.env("FABRO_DEV_TOKEN", token) when dev-token auth enabled
|
||||
|
||||
RENDER_GRAPH list:
|
||||
PATH, HOME, TMPDIR
|
||||
+ explicit cmd.env("FABRO_TELEMETRY", "off")
|
||||
|
||||
DAEMON spawn: no helper. Inherits parent env. Keeps existing cmd.env_remove("FABRO_JSON").
|
||||
```
|
||||
|
||||
The lists are constants in `spawn_env.rs`. Each entry is one named env var with a one-line `//` comment. A worker that needs a new env var must be amended in source — that's the entire mechanism.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (worker): with allowlisted names in parent env, all reach the worker. Random names (`MY_API_KEY`, `NEW_RELIC_LICENSE_KEY`, `DATABASE_URL`, `SESSION_SECRET=leak`) do not.
|
||||
- Happy path (worker, dev-token enabled): `FABRO_DEV_TOKEN` is set to the install-provisioned value via explicit re-injection.
|
||||
- Negative (worker): parent's `FABRO_DEV_TOKEN=garbage` does not reach the worker; explicit re-injection sets the correct value.
|
||||
- Happy path (render-graph): `PATH` and `HOME` reach the child; arbitrary parent vars do not.
|
||||
- Integration (render-graph): with `FABRO_TELEMETRY=on` in parent env, child sees `off` (explicit override after `env_clear`).
|
||||
- Integration: real worker subprocess executes a workflow end-to-end with leak probes (`MY_API_TOKEN=leak`, `NEW_RELIC_LICENSE_KEY=leak`) exported in parent env. Workflow completes; leak probes do not appear in worker logs or in stage env (Sandbox-configured).
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-server` passes.
|
||||
- A test asserts the worker spawn sees *only* names in the allowlist plus the explicit `FABRO_DEV_TOKEN` — i.e. "no ambient inheritance except allowlisted names." Same for render-graph. The check operates by enumerating the child's actual env, not by greping `env_remove` calls.
|
||||
- `grep -rn 'env_remove' lib/crates/fabro-server/src/` returns no hits for the worker (`server.rs:3776-3834`) or render-graph (`server.rs:7232-7235`) spawn sites — both routes are now via `env_clear` + the helpers.
|
||||
- `grep -rn 'env_remove' lib/crates/fabro-cli/src/commands/server/start.rs` returns the single intentional `cmd.env_remove("FABRO_JSON")` on the daemon-spawn path (output-format hygiene; daemon child unchanged per Unit 2). No other `env_remove` calls in CLI server code.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 4: Shared startup validation via snapshot-backed `ServerSecrets`**
|
||||
|
||||
**Goal:** Server start no longer auto-generates. Daemon preflight constructs the same `ServerSecrets` snapshot foreground startup uses and runs the *same* server-side auth/startup validation logic. No separate parent-CLI validator that could drift from the server-side rules.
|
||||
|
||||
**Requirements:** R1, R2, R7
|
||||
|
||||
**Dependencies:** Units 1-3.
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-server/src/startup.rs` (or extend an existing module) — define the shared validation logic. Two entry points around it: a public preflight wrapper (CLI calls this; never sees `ServerSecrets`), and a crate-internal full-resolution function (`serve_command` calls this; consumes the snapshot directly). Both share a single internal implementation so there is no possibility of drift.
|
||||
|
||||
```text
|
||||
// Crate-internal: returns full state for in-process consumption.
|
||||
pub(crate) struct StartupResolution {
|
||||
pub(crate) auth_mode: AuthMode,
|
||||
pub(crate) server_secrets: ServerSecrets,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_startup(
|
||||
env_path: &Path,
|
||||
env: &dyn EnvSource,
|
||||
settings: &ResolvedServerSettings,
|
||||
) -> Result<StartupResolution, StartupValidationError>
|
||||
|
||||
// Public: thin wrapper for CLI preflight. Calls resolve_startup, drops the
|
||||
// StartupResolution after validating, returns just the success/failure.
|
||||
pub fn validate_startup(
|
||||
env_path: &Path,
|
||||
env: &dyn EnvSource,
|
||||
settings: &ResolvedServerSettings,
|
||||
) -> Result<(), StartupValidationError>
|
||||
```
|
||||
|
||||
Internally `resolve_startup` constructs `ServerSecrets::load(env_path, env)`, then runs the existing `jwt_auth::resolve_auth_mode_with_lookup` against a closure delegating to that snapshot, then returns both. `validate_startup` is `resolve_startup(...).map(|_| ())` — the literal sharing of code makes drift impossible.
|
||||
|
||||
Function takes `&ResolvedServerSettings` (not just `&ServerAuthSettings`) because validation already depends on `server.web.enabled` and `server.integrations.github.client_id` (`jwt_auth.rs:63`) — narrowing would silently weaken the validation surface.
|
||||
|
||||
`StartupValidationError` *wraps or re-uses the existing error type* returned by `jwt_auth::resolve_auth_mode_with_lookup` plus the secret-loading errors from `ServerSecrets::load`. It must cover the full surface that path rejects today: missing required secret (`SESSION_SECRET`, `FABRO_DEV_TOKEN` when dev-token auth enabled, `GITHUB_APP_CLIENT_SECRET` when GitHub auth enabled), invalid secret value (e.g., malformed `FABRO_DEV_TOKEN`), empty auth methods, GitHub auth configured with `server.web.enabled = false`, missing `server.integrations.github.client_id`. Implementer audits `jwt_auth.rs:67` to enumerate the full variant set; the plan does not invent a narrow new one.
|
||||
|
||||
CLI never sees `ServerSecrets`. `ServerSecrets` and `resolve_startup` stay `pub(crate)`. Only `validate_startup` + `EnvSource` + `ProcessEnv` + `StartupValidationError` cross the crate boundary.
|
||||
- Modify: `lib/crates/fabro-server/src/lib.rs` — re-export `startup::{validate_startup, EnvSource, ProcessEnv, StartupValidationError}` from the crate root. **Not** `resolve_startup` or `StartupResolution`.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:229-250` — delete `load_or_create_local_session_secret`. Daemon preflight calls `fabro_server::validate_startup(runtime_directory.env_path(), &fabro_server::ProcessEnv, &resolved_settings)`. On `Err`: surface the error to stderr exactly as returned (text comes from the shared error type's `Display`).
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:264` — `execute_foreground` does not run a separate validator. The in-process server's `serve_command` calls `resolve_startup` internally and threads the returned `(ServerSecrets, AuthMode)` into `build_app_state` (replacing today's separate `ServerSecrets::with_env_lookup` + `resolve_auth_mode_with_lookup` calls at `serve.rs:594-607`). Single source of truth: foreground's resolution and daemon's preflight share the same internal `resolve_startup`; only the calling surface differs.
|
||||
- Modify: `lib/crates/fabro-server/src/serve.rs:594-607` — replace today's ad-hoc two-step construction with a single `resolve_startup(...)` call. The returned `ServerSecrets` and `AuthMode` are passed into `build_app_state` (per Unit 1's revised signature).
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:350` — `execute_daemon` runs `validate_startup` before spawning the child.
|
||||
- Test: `tests/it/cmd/server_start.rs` adds missing-secret tests for each required secret across both modes, plus tests demonstrating env-source success, file-source success, and each non-missing-key rejection (empty auth methods, web-disabled with GitHub auth, missing client_id, invalid dev token). A unit test in `fabro-server` asserts `validate_startup` and `resolve_startup` return identical accept/reject decisions for the same inputs.
|
||||
|
||||
**Approach:**
|
||||
- Required secrets remain *exactly* the current startup-critical set, encoded once in the shared validation: SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth is enabled), GITHUB_APP_CLIENT_SECRET (when GitHub auth is enabled). GITHUB_APP_PRIVATE_KEY and GITHUB_APP_WEBHOOK_SECRET remain in-scope server secrets but are NOT new universal boot blockers — they continue to surface where they did before (conditional route mounts, per-request errors).
|
||||
- Daemon preflight and foreground startup run the same validation function with the same `ServerSecrets` snapshot type; "drift" is impossible by construction.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: required secrets in env → boots (both modes).
|
||||
- Happy path: required secrets in file → boots (both modes).
|
||||
- Happy path: env wins when both present.
|
||||
- Error path (each required secret): missing in both → fail fast naming the secret and both sources (both modes get identical error text because they call the same function).
|
||||
- Negative regression: post-Unit-1 snapshot is built once; mutating env after boot doesn't change the resolved value.
|
||||
|
||||
**Verification:**
|
||||
- `fabro server start` against an empty env and uninstalled storage exits non-zero with the same error message in both `--foreground` and daemon modes.
|
||||
- `fabro server start` with secrets in env (12-factor simulation) boots without any install flow having run.
|
||||
- `grep -rn 'generate_session_secret' lib/crates/fabro-cli/src/commands/server/` returns no hits.
|
||||
- `grep -rn 'ServerSecrets\|server_secrets::\|resolve_startup\|StartupResolution' lib/crates/fabro-cli/` returns no hits — CLI never sees `ServerSecrets` or the internal resolution. Only `validate_startup` is reachable from outside `fabro-server`.
|
||||
- The public secret-related surface from `fabro-server`'s crate root is exactly: `validate_startup`, `EnvSource`, `ProcessEnv`, `StartupValidationError`. Nothing else.
|
||||
- Foreground startup at `serve.rs:594-607` makes exactly one call to compute `(ServerSecrets, AuthMode)` — `resolve_startup(...)` — not a separate `ServerSecrets::with_env_lookup` followed by `resolve_auth_mode_with_lookup`. Both values are passed into `build_app_state`.
|
||||
- `build_app_state` no longer constructs `ServerSecrets`; it accepts pre-built `ServerSecrets` and `AuthMode` from the caller. Verified by reading the signature.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 5: Shared install orchestration for coordinated `server.env` writes and restart handoff**
|
||||
|
||||
**Goal:** CLI install and web install share a single orchestration layer for `server.env` persistence. Both flows take the same path through generation, persistence, removal of legacy entries, and restart handoff. Web install keeps its existing `/install/finish` restart-handoff contract; CLI install joins the same orchestration. No blanket refuse-while-running policy.
|
||||
|
||||
**Requirements:** R8
|
||||
|
||||
**Dependencies:** Pairs naturally with Unit 6 (legacy `FABRO_JWT_*` removal hooks into the same orchestration). Either order works.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-install/src/lib.rs` — establish the shared orchestration entry points. Both CLI install and web install call into these. Orchestration owns:
|
||||
- input gathering (no `server.env` write, no serialization)
|
||||
- persistence (atomic `server.env` write via existing `envfile::merge_env_file` at `envfile.rs:204-256`)
|
||||
- legacy entry removal (Unit 6 hook for `FABRO_JWT_*`)
|
||||
- restart/handoff (web install via `/install/finish`'s existing restart URL contract; CLI install determines its own handoff — for the in-process case, exit cleanly; for daemon mode, respect today's `fabro server stop` + restart pattern)
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/install.rs:1864, 1213` — route `server.env` writes through the shared orchestration in `fabro-install`. CLI-specific UX (prompts, progress display) remains in `commands/install.rs`; persistence does not.
|
||||
- Modify: `lib/crates/fabro-server/src/install.rs:1313, 1343-1353, 1380` — server-side `/install/finish` handlers route through the same orchestration. The existing `/install/finish` API contract (returns restart URL; SPA polls until server returns) is preserved exactly — no `restart_required` flag, no API surface change.
|
||||
- Test: parity tests in `lib/crates/fabro-install/tests/` prove CLI install and web install take the same persistence/removal path with the same outputs given the same inputs.
|
||||
|
||||
**Approach:**
|
||||
- The shared orchestration in `fabro-install` is the single chokepoint for `server.env` writes from install flows. Manual edits to `server.env` outside the orchestration still require operator restart discipline (documented).
|
||||
- Coordinated install flows MAY write while a server is running — they own restart/handoff. Web install already has the handoff via `/install/finish`; CLI install gets the same primitives.
|
||||
- Two-phase shape: `gather inputs (no serialization)` → `persist + restart-handoff (atomic)`. Serialization window is sub-second regardless of OAuth duration.
|
||||
- No PID-comparison logic, no refuse-while-running predicate, no new chokepoint helper in `fabro-config`. The earlier `write_server_env_serialized` proposal is dropped.
|
||||
|
||||
**Test scenarios:**
|
||||
- Parity: identical install inputs produce identical `server.env` contents and identical removed entries via CLI and web paths.
|
||||
- Happy path (CLI): install on stopped server succeeds; `server.env` updated atomically.
|
||||
- Happy path (web): install on running server completes via `/install/finish`; restart URL returned; SPA reconnects after restart.
|
||||
- Legacy removal: install (CLI or web) on a `server.env` containing `FABRO_JWT_*` entries leaves the file without them (Unit 6 hook).
|
||||
- Negative regression: install-then-start works in the common single-shell CLI sequence.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-install -p fabro-cli` passes including parity tests.
|
||||
- `grep -rn 'envfile::write_env_entries\|envfile::merge_env_file' lib/crates/fabro-cli/src lib/crates/fabro-server/src` shows `server.env` writes routed through `fabro-install` orchestration; no direct calls outside it.
|
||||
- `/install/finish` API contract unchanged (existing OpenAPI schema and SPA reconnect tests pass without modification).
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 6: Remove legacy `FABRO_JWT_*` drift**
|
||||
|
||||
**Goal:** Stop generating `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, stop reading them in diagnostics, remove operator/docs references that describe them as auth inputs, and actively remove existing entries from `server.env` during install flows. SESSION_SECRET is the sole auth master.
|
||||
|
||||
**Requirements:** R9
|
||||
|
||||
**Dependencies:** None for code shape; the active-removal hook plugs into Unit 5's shared install orchestration.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/install.rs:1853-1855` — delete `generate_jwt_keypair()` call and the corresponding `("FABRO_JWT_PRIVATE_KEY", ...)` / `("FABRO_JWT_PUBLIC_KEY", ...)` entries from `generated_server_env_pairs`.
|
||||
- Modify: `lib/crates/fabro-server/src/install.rs` (whichever lines mirror the CLI side, surfaced in research) — same deletion on the web install side.
|
||||
- Modify: `lib/crates/fabro-install/src/lib.rs` (Unit 5's shared orchestration) — add `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` to a `legacy_keys_to_remove` set; the persistence step writes these as removals on every install run.
|
||||
- Modify: `lib/crates/fabro-server/src/diagnostics.rs:540, 552` — remove the `state.server_secret("FABRO_JWT_PUBLIC_KEY")` and `state.server_secret("FABRO_JWT_PRIVATE_KEY")` checks. Adjust diagnostics output and tests accordingly.
|
||||
- Modify: **all** operator-facing references to `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` across `docs/`, `apps/marketing/`, README, and any in-repo runbook. The criterion is "any mention," not "mention as auth input." Concrete known-stale targets:
|
||||
- `docs/administration/server-configuration.mdx:297` — remove the "future CLI login flows" reference and any surrounding prose treating these as install/runtime secrets.
|
||||
- `docs/administration/server-configuration.mdx:331` — remove the row(s) from the "Server authentication" table.
|
||||
- Implementer must also `grep -rn 'FABRO_JWT_PRIVATE_KEY\|FABRO_JWT_PUBLIC_KEY' docs/ apps/ README*` and either delete or rewrite every hit. Surviving mentions should appear only in (a) the new strategy doc's "removed" section or (b) historical plans under `docs/plans/`.
|
||||
- Modify: install snapshot tests, server.env fixture files, and any insta snapshots that assert on `FABRO_JWT_*` lines — regenerate.
|
||||
- Test: install run against a `server.env` pre-seeded with `FABRO_JWT_*` entries — assert they are absent after install completes.
|
||||
|
||||
**Approach:**
|
||||
- Deletion is the entire change. No deprecation period; no compatibility shim. The keys haven't participated in runtime auth since the CLI auth login migration (`2026-04-19-003`).
|
||||
- Operators upgrading run install once; legacy entries are silently cleaned up. The strategy doc records the cleanup for any operator who notices.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: install on a fresh storage dir produces a `server.env` with no `FABRO_JWT_*` entries.
|
||||
- Happy path (cleanup): install on a `server.env` containing `FABRO_JWT_PRIVATE_KEY=...` and `FABRO_JWT_PUBLIC_KEY=...` produces an updated `server.env` without those keys, with other entries preserved.
|
||||
- Diagnostics: `fabro doctor` (or whichever command surfaces diagnostics) does not mention `FABRO_JWT_*`.
|
||||
|
||||
**Verification:**
|
||||
- `grep -rn 'FABRO_JWT_PRIVATE_KEY\|FABRO_JWT_PUBLIC_KEY\|generate_jwt_keypair' lib/crates apps/ docs/ README*` returns hits *only* in (a) the new strategy doc's "removed" section, (b) historical plan files under `docs/plans/`. No hits in operator-facing docs (`docs/administration/`, `docs/quickstart/`, marketing) or production crate code.
|
||||
- `cargo nextest run -p fabro-cli -p fabro-server` passes with regenerated snapshots.
|
||||
- Mintlify docs build succeeds with the removed entries (no broken anchors/links from other pages that referenced the JWT-key sections).
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 7: Strategy doc + workspace-wide clippy enforcement**
|
||||
|
||||
**Goal:** Document the design and encode the rules as compile-time enforcement applied to *all* code including tests.
|
||||
|
||||
**Requirements:** R10
|
||||
|
||||
**Dependencies:** Units 1-6.
|
||||
|
||||
**Files:**
|
||||
- Create: `docs-internal/server-secrets-strategy.md`.
|
||||
- Modify: `clippy.toml` — add `std::env::set_var` and `std::env::remove_var` to `disallowed-methods` with reason text pointing at the strategy doc. The ban is workspace-wide and applies to tests as well as production code.
|
||||
- Modify: `lib/crates/fabro-telemetry/src/spawn.rs:73-76` — add `#[expect(clippy::disallowed_methods, reason = "post-fork pre-execvp env mutation; safe because grandchild is single-threaded and about to be replaced via exec")]`.
|
||||
- Modify: any other production caller surfaced during implementation (the count is small per Unit 1's grep) — same `#[expect]` pattern with documented reason.
|
||||
- Modify: `CLAUDE.md` (and `AGENTS.md` if separate) — add "Strategy docs" entry pointing at the new doc.
|
||||
|
||||
**Approach:**
|
||||
|
||||
Strategy doc covers:
|
||||
- **`ServerSecrets` is the resolved-secrets API.** Sources are process env (12-factor) and `<storage>/server.env` (install/local convenience). Snapshotted at boot via `EnvSource` trait that materializes immediately into owned `HashMap`. Env wins on conflict.
|
||||
- **The five active server-level secrets** and their consumers (table from this plan). `FABRO_JWT_*` is removed (Unit 6); SESSION_SECRET is the sole auth master via HKDF.
|
||||
- **Provisioning paths:** CLI install, web install (shared orchestration in `fabro-install`), or platform env. Server start does not auto-generate.
|
||||
- **Tests must not mutate process env.** Enforced workspace-wide by clippy. Tests inject via construction-time `EnvSource` stubs (e.g. `StubEnv`) for in-process resolution, or via explicit child-process `Command::env` for subprocess simulation.
|
||||
- **Worker and render-graph env:** `env_clear` + strict fail-closed allowlist. Daemon child inherits parent env (12-factor pattern). New worker env entries require demonstrated need + intentional addition.
|
||||
- **Install while running:** allowed only through the shared install orchestration which owns restart/handoff. Manual `server.env` edits still require restart discipline.
|
||||
- **Rotation:** restart required. Live rotation intentionally not supported. Compliance-driven N+1 rotation (overlap windows) is a known limitation tracked as follow-up.
|
||||
- **Out of scope (with reasons):** Vault + `ProviderCredentials`, `vault_or_env` for run-level credentials (different track), `{{ env.FOO }}` config interpolation, workflow-stage env (Sandbox's job), `validate_api_key` `set_var` smell, Tailscale spawns, `bun --watch-web`.
|
||||
- **Adding a new server-level secret:** (1) provision via the install orchestration or platform env; (2) consume via `state.server_secret(...)`; (3) do not touch env in any other layer; (4) decide if it joins the startup-critical set (most don't).
|
||||
- **Adding a new worker env var:** add to the worker list in `spawn_env.rs` with a one-line reason and a failing-without test that proves need.
|
||||
|
||||
**Verification:**
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` passes.
|
||||
- Adding a new `std::env::set_var(...)` in any production OR test file produces a clippy denial that names the strategy doc.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** all active server-level secrets (the five remaining after Unit 6) converge through `state.server_secret(name)` after Unit 1, which returns from the env+file snapshot. `resolve_auth_mode_with_lookup` migrates in the same unit. Legacy `FABRO_JWT_*` is removed across install, diagnostics, and docs (Unit 6).
|
||||
- **Error propagation:** daemon preflight (`validate_startup`) and foreground startup (`resolve_startup`, called from `serve_command`) both delegate to the same shared validation logic introduced in Unit 4 — the public preflight wrapper is literally `resolve_startup(...).map(|_| ())`, so error messages and accept/reject decisions are identical by construction. Unit 1 provides only the snapshot machinery (`ServerSecrets` + `EnvSource`) that Unit 4's validation consumes. Object-store credential failures still surface from the AWS SDK / object_store crate (unchanged — daemon inherits AWS env).
|
||||
- **State lifecycle:** `ServerSecrets` snapshot built once at boot from env+file; both immutable for process lifetime. Rotating any in-scope secret requires restart. Install flows MAY update `server.env` while a server is running when they own restart/handoff via the shared install orchestration (Unit 5).
|
||||
- **Subprocess env:** workers and render-graph processes inherit only an explicit fail-closed allowlist. Daemon child inherits parent env — that's how 12-factor SESSION_SECRET reaches the daemon.
|
||||
- **API surface:** no public API change. `state.server_secret(...)` signature unchanged. `ServerSecrets::with_env_lookup` removed; replaced by `load(path, &dyn EnvSource)`. `/install/finish` API contract unchanged (no `restart_required` flag, no new fields).
|
||||
- **Unchanged invariants:** `ProviderCredentials`, Vault REST API, `vault_or_env` for run-level credentials, `{{ env.FOO }}` interpolation, workflow stages (configured by `Sandbox`) all behave exactly as before.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Snapshot semantics surprise: operator changes env after boot, expects the running server to pick it up | Documented behavior. Live rotation is intentionally not supported; restart required. Strategy doc is explicit. |
|
||||
| Worker list is missing something a workflow stage runner inside Sandbox needs | Worker process itself only does HTTP callbacks — Sandbox handles stage env. Real workflow integration test in Unit 3 verification catches false negatives. |
|
||||
| Existing deployments without `server.env` AND without env-set secrets fail to start | Intended behavior; error message names both sources. Per repo policy, accept the breakage. |
|
||||
| Test migration: replacing `std::env::set_var` calls with `EnvSource` stubs touches many files | `EnvSource` + `StubEnv` pattern is mechanical; one helper per pattern. Unit 7's clippy enforcement catches stragglers at compile time so nothing slips through. |
|
||||
| Shared install orchestration regression breaks CLI/web parity | Parity tests in Unit 5 explicitly assert identical persistence behavior across CLI and web paths. Both flows route through the same `fabro-install` entry points. |
|
||||
| Automatic `FABRO_JWT_*` removal surprises operators who believed those keys were authoritative | Strategy doc names the cleanup explicitly. Keys haven't participated in runtime auth since `2026-04-19-003`; cleanup is overdue, not novel. Operators upgrading run install once and the cleanup happens silently. |
|
||||
| Rust 2024 edition migration is a separate effort | Removing `set_var` is a prerequisite; this work doesn't gate on the edition migration. Workspace-wide clippy enforcement (Unit 7) prevents reintroduction including in tests. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- **Operator-facing change:** `fabro server start` against an empty env AND uninstalled storage now fails fast naming both sources. Document in install/quickstart.
|
||||
- **12-factor PaaS deployments (Railway, Heroku, Fly):** unchanged ergonomics — set secrets in platform env, run `fabro server start`. Works without `fabro install` having run on the platform.
|
||||
- **Container/k8s deployments:** if secrets are mounted as files (e.g. via projected volume into `<storage>/server.env`) or set as env vars (k8s Secret → env), both work.
|
||||
- **Cloud object-store (S3 / IRSA / ECS):** unchanged — daemon inherits AWS env from parent, object-store layer reads ambient credentials.
|
||||
- **Install while server running:** install flows may update `server.env` on a running server only when they coordinate restart/handoff via the shared install orchestration (CLI install or web install via `/install/finish`). Manual edits to `server.env` outside the orchestration still require restart discipline.
|
||||
- **`FABRO_JWT_*` removal:** `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` are removed from the runtime auth model; SESSION_SECRET is the sole auth master for cookie and JWT derivation. Existing legacy entries are cleaned up automatically on subsequent install flows.
|
||||
- **Rotation:** edit env (and restart) or edit `server.env` (and restart). Live rotation not supported.
|
||||
- **Logging:** the fail-fast error appears in operator log aggregators. Pair human-readable message with a structured error code (e.g., `error_code=missing_session_secret`) so log searches match without depending on the exact string.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related plans:
|
||||
- `docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md` — architectural anchor
|
||||
- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` — SESSION_SECRET as HKDF master
|
||||
- `docs/plans/2026-04-18-001-feat-webhook-strategy-plan.md` — webhook secret precedent
|
||||
- `docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md` — origin of daemon/foreground split
|
||||
- Related commits:
|
||||
- `7cb6c65d5` — "Remove dev-token minting from server start" (precedent for Unit 4)
|
||||
- Strategy doc precedent: `docs-internal/logging-strategy.md`, `docs-internal/events-strategy.md`
|
||||
570
docs/plans/2026-04-22-004-refactor-worker-jwt-auth-plan.md
Normal file
570
docs/plans/2026-04-22-004-refactor-worker-jwt-auth-plan.md
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
---
|
||||
title: "refactor: Server-issued per-run JWT for worker subprocess auth"
|
||||
type: refactor
|
||||
status: completed
|
||||
date: 2026-04-22
|
||||
deepened: 2026-04-22
|
||||
---
|
||||
|
||||
# refactor: Server-issued per-run JWT for worker subprocess auth
|
||||
|
||||
## Overview
|
||||
|
||||
Server mints one per-run JWT (HS256, 72h, claims include `run_id`) at every worker subprocess spawn, injects it into the worker env as `FABRO_WORKER_TOKEN`, and worker-touched run-scoped routes accept it. Worker stops reading `~/.fabro/auth.json`. The artifact-upload-token mechanism is deleted entirely (greenfield — no external consumers, no shim required). End-user auth (dev-token / github) is now strictly orthogonal to worker auth.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Workers POST back to the server (events, state, blobs, stage artifacts). Today the worker only authenticates because it inherits the CLI user's OAuth session from `~/.fabro/auth.json` (`fabro-cli/src/server_client.rs:312` → `AuthStore::default()` at `fabro-client/src/auth_store.rs:107`). Side effects:
|
||||
|
||||
- (a) worker authenticates *as the user* — events emitted by the worker get user identity, not "system";
|
||||
- (b) any deployment where the worker doesn't share a home dir with an authenticated CLI user (containerized server, `fabro` system user, remote worker, multi-tenant) silently fails;
|
||||
- (c) worker auth is implicitly coupled to end-user auth strategy when conceptually independent.
|
||||
|
||||
GitHub-only install (`auth.methods = ["github"]`) writes no `FABRO_DEV_TOKEN` and `worker_command` (`server.rs:3740-3750`) injects nothing — the worker has no documented credential at all. Only the home-dir steal makes it work.
|
||||
|
||||
The artifact-upload-token mechanism (`server.rs:752`, `server.rs:846`) already proves the right pattern for one route. Generalize it to every worker-touched route.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Worker subprocess authenticates to server with a credential the server explicitly issued, not one stolen from the user's home directory.
|
||||
- R2. Credential is per-run (claim `run_id` must match path `run_id`); cross-run reuse rejected.
|
||||
- R3. Credential survives server restart up to its natural 72h expiry (no operational events shortening the ceiling).
|
||||
- R4. Both `start` and `resume` spawn paths re-mint a fresh 72h credential.
|
||||
- R5. Worker auth works in any deployment topology, including GitHub-only installs with no `~/.fabro/auth.json` on the worker host.
|
||||
- R6. Worker-emitted events stamped as a system principal (`system:worker`); originator user identity remains discoverable on the run record.
|
||||
- R7. Worker-touched run-scoped routes still accept end-user JWTs for non-worker callers (CLI, web UI) — fall-through, not replacement.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **Greenfield context:** no shipped deployments; no need to preserve in-flight workers across the change. Atomic swap. No backwards-compat shim. No deprecation cycle.
|
||||
- Out: refresh tokens for workers (decided: hard 72h ceiling per spawn, fresh mint on resume).
|
||||
- Out: in-memory or persisted revocation list. Per-run binding + 72h `exp` is the entire blast-radius bound.
|
||||
- Out: multi-host / remote worker spawning (this plan makes it *possible*, doesn't deliver it).
|
||||
- Out: changes to end-user auth methods (`ServerAuthMethod::DevToken | Github`).
|
||||
- Out: any new `RunAuthMethod` variant — worker token bypasses `AuthenticatedSubject` entirely.
|
||||
- Out: extending `RunSummary` with provenance — originator already on `RunSpec.provenance.subject` and that's enough.
|
||||
- Out: SSE attach routes (`/runs/{id}/attach`, `/attach`). Worker is a producer, not a consumer; never calls them. Stay user-JWT-only.
|
||||
- Out: lifecycle/admin/user-action routes (`/runs/{id}/cancel`, `/pause`, `/unpause`, `/archive`, `/unarchive`, `DELETE /runs/{id}`, `submit_answer`, `start_run`, `create_run`, list endpoints). Worker token explicitly rejected on these — they remain user-JWT-only.
|
||||
- Out: any `Display` impl on `Credential::Worker` payload. Plan keeps redacted `Debug` only; do not add `Display`.
|
||||
- Out: distinct exit codes for auth failure. Worker bails clearly at startup if env is missing; server 401/403 mid-run flows through normal client error handling.
|
||||
- Out: blanket env scrubbing of trusted internal subprocesses (`gh auth token`, MCP servers, devcontainer setup, git). These may legitimately need credentials. Scrubbing scope: workflow/sandbox stage-execution chokepoint (`LocalSandbox::execute`) AND host-mode hooks (defense-in-depth; shell commands have no business reading the worker token).
|
||||
|
||||
## Threat Model
|
||||
|
||||
State the assumptions explicitly so reviewers and operators can challenge them.
|
||||
|
||||
- **Trust boundary:** the server process and any worker subprocess running under the same OS user are mutually trusted. Same-UID attackers (or a workflow stage that compromises the worker process) can read `FABRO_WORKER_TOKEN` from `/proc/<pid>/environ` on Linux. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers / namespaces. Cross-run isolation between same-UID workers is NOT a property of this design.
|
||||
- **`SESSION_SECRET` is the master key.** It signs both user JWTs and worker JWTs (via distinct HKDF context labels). Any leak vector — backup including `server.env`, env dump in logs, ECS task definition exposure, accidental commit, breadcrumb capture — gives the attacker the ability to mint tokens of either kind for any user / any run. Operational guidance: store `SESSION_SECRET` in a secrets manager, exclude from logs/Sentry, document a rotation procedure (rotation invalidates ALL outstanding worker tokens AND ALL user sessions — accept as the cost of compromise response).
|
||||
- **Worker token compromise:** an attacker who exfiltrates a single worker token gains read/write on that one run's events/blobs/state for up to 72h. Run-id binding limits cross-run damage. There is no in-product revocation; rotating `SESSION_SECRET` is the only mechanism to invalidate outstanding worker tokens.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `lib/crates/fabro-server/src/server.rs:287-289, 752-812, 813-854` — artifact-upload-token: claims struct, key generation (`OsRng` per boot), mint, "service token first, else user JWT" check (`authorize_artifact_upload`). Generalize the shape to all worker-touched routes; replace this mechanism wholesale.
|
||||
- `lib/crates/fabro-server/src/server.rs:3701-3755` — `worker_command`: single spawn site for both `start` and `resume`. Already passes `--artifact-upload-token` via argv (deleted in Unit 3) and calls `apply_worker_env` at `server.rs:3739`. Add `cmd.env("FABRO_WORKER_TOKEN", token)`.
|
||||
- `lib/crates/fabro-server/src/server.rs:4666` — `execute_run_subprocess` calls `worker_command` once per spawn; `RunExecutionMode` flows from `start_run` (`server.rs:4181`) and `create_run` (`server.rs:4011`). Single mint site covers both modes.
|
||||
- `lib/crates/fabro-server/src/spawn_env.rs:18` — existing `apply_worker_env` does `env_clear` + 8-name allowlist (PATH, HOME, TMPDIR, USER, RUST_LOG, RUST_BACKTRACE, FABRO_HOME, FABRO_STORAGE_ROOT). `SESSION_SECRET`, `FABRO_JWT_*`, `GITHUB_APP_*` are all already excluded. Existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) asserts `SESSION_SECRET` is stripped.
|
||||
- `lib/crates/fabro-server/src/auth/keys.rs:41` — existing HKDF helper `derive_jwt_key` for the user-JWT key. Mirror for worker JWT with distinct context label `b"fabro-worker-jwt-v1"` so worker keys survive server restarts (R3).
|
||||
- `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127` — `__run-worker` entry, `HttpRunStore`, `HttpArtifactUploader`. Only seven server endpoints touched (catalogued below).
|
||||
- `lib/crates/fabro-cli/src/server_client.rs:51-58, 133, 312` — `connect_server_target_direct` → `connect_target_api_client_bundle` → `resolve_target_credential` → `AuthStore::default()`. Sole worker caller is `runner.rs:67`. Replaced for the worker only via a sibling constructor.
|
||||
- `lib/crates/fabro-client/src/credential.rs:6-30` — `Credential` enum. Add `Worker(String)` variant; `bearer_token()` returns the string.
|
||||
- `lib/crates/fabro-types/src/run_event/mod.rs:29-81` — `ActorRef`/`ActorKind { User | Agent | System }`. No new variant needed — stamp worker events with `ActorKind::System`.
|
||||
- `lib/crates/fabro-types/src/run.rs:34-49` — `RunProvenance`/`RunSubjectProvenance` already on `RunSpec`. Originator preserved at run-creation time; no schema change.
|
||||
- `lib/crates/fabro-sandbox/src/local.rs:43-66, 221` — `LocalSandbox::execute` does `env_clear` + `should_filter_env_var` heuristic for stage commands. The `_token` suffix filter incidentally catches `FABRO_WORKER_TOKEN`; make it explicit (denylist entry).
|
||||
|
||||
### Worker → server endpoint surface
|
||||
|
||||
These are the **only** routes that gain worker-token acceptance. Lifecycle/admin/list endpoints stay user-JWT-only (see Scope Boundaries).
|
||||
|
||||
| Worker call | HTTP | Path | Server handler | Auth today |
|
||||
|---|---|---|---|---|
|
||||
| `client.get_run_state` | GET | `/runs/{id}/state` | `get_run_state` (`server.rs:5076`) | `AuthenticatedService` |
|
||||
| `client.list_run_events` | GET | `/runs/{id}/events` | `list_run_events` (`server.rs:5146`) | `AuthenticatedService` |
|
||||
| `client.append_run_event` | POST | `/runs/{id}/events` | `append_run_event` (`server.rs:5096`) | `AuthenticatedService` |
|
||||
| `client.write_run_blob` | POST | `/runs/{id}/blobs` | `write_run_blob` (`server.rs:5352`) | `AuthenticatedService` |
|
||||
| `client.read_run_blob` | GET | `/runs/{id}/blobs/{blobId}` | `read_run_blob` (`server.rs:5379`) | `AuthenticatedService` |
|
||||
| `client.upload_stage_artifact_file` | POST | `/runs/{id}/stages/{stageId}/artifacts` (octet-stream) | `put_stage_artifact` (`server.rs:5838`) | `authorize_artifact_upload` |
|
||||
| `client.upload_stage_artifact_batch` | POST | same path (multipart) | same handler | same |
|
||||
|
||||
### Coordination with concurrent plans
|
||||
|
||||
- `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md` partially landed: `apply_worker_env` exists at `lib/crates/fabro-server/src/spawn_env.rs:18` and is invoked from `worker_command` at `server.rs:3739`. The allowlist excludes server-only secrets, structurally preventing the worker from inheriting `SESSION_SECRET`. This plan adds the `FABRO_WORKER_TOKEN` re-injection alongside the existing `FABRO_DEV_TOKEN` re-injection (and ultimately replaces the latter).
|
||||
- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` Unit 8 created `lib/crates/fabro-server/src/auth/jwt.rs` (`Claims`, `issue`, `verify`, `JwtError`) and `auth/keys.rs::derive_jwt_key`. Reuse the HKDF derivation pattern (distinct context label) and the `jsonwebtoken` primitives directly — do not route worker-token claims through user-`JwtSubject`.
|
||||
- `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md` deliberately closed the "trust local files because same host" pattern. This plan preserves that closure — no new same-host exceptions; the worker uses an explicitly-passed token.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No `docs/solutions/` directory exists. Prior decisions live in `docs/plans/`.
|
||||
- Artifact-upload-token TTL precedent is 24h. New worker-token TTL is 72h — justified because worker tokens must survive long human-in-the-loop pauses with no in-process refresh.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Replace artifact-upload-token entirely; one JWT per run covers all worker-touched run-scoped routes | Two parallel per-run JWTs is bookkeeping. Run-id binding gives the same blast-radius constraint without a separate scope. Greenfield → atomic delete, no shim. |
|
||||
| Pass JWT to worker via env var `FABRO_WORKER_TOKEN`. **Env-only — never argv.** | Symmetric with existing `FABRO_DEV_TOKEN` re-injection model. Plays naturally with `env_clear` + explicit re-injection in `apply_worker_env`. Avoids token strings in `ps` output. |
|
||||
| HS256, key derived from `SESSION_SECRET` via HKDF (context `b"fabro-worker-jwt-v1"`) | Workers must survive server restarts up to natural 72h expiry (R3). `OsRng`-per-boot defeats the long TTL. Distinct context label keeps it isolated from the user-JWT key. Operator rotation of `SESSION_SECRET` invalidates outstanding worker tokens — accepted (and is the only revocation mechanism). |
|
||||
| 72h TTL, no refresh, no revocation list | Long-paused runs need a generous outer ceiling. Resume re-mints. Workers running > 72h continuously fail loudly — acceptable outer bound. Adding revocation requires persistent state and creates restart-window contradictions; not worth the complexity for the current threat model. |
|
||||
| Per-run `run_id` claim, path-vs-claim check | Mirrors `maybe_authorize_artifact_upload_token`. Cross-run reuse → 403. |
|
||||
| Add `Credential::Worker(String)` variant (not reuse `DevToken`) | Debug printing stays accurate. No `Display` impl — compile-time hardening prevents accidental `format!`-leaks. |
|
||||
| New worker-only client constructor `connect_server_target_with_bearer(target, token)`, bypasses `AuthStore`/`OAuthSession` entirely | Worker should never read user OAuth. Surgical to fix at the worker callsite (one caller, `runner.rs:67`) rather than gating `resolve_target_credential` with a "are you a worker" flag. |
|
||||
| Stamp `system:worker` actor in a worker-side sink wrapper inside `RunEventSink::fanout`, NOT in `to_run_event_at` | `to_run_event_at` and `stored_event_fields` are shared with the server (server flushes lifecycle events through `workflow_event::to_run_event` at `server.rs:6702`). Default-filling there mis-stamps server-emitted events. The wrapper is worker-local. |
|
||||
| Route API is a set of typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`), NOT a bare helper function | Composes with existing `Json<...>` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path<String>` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. |
|
||||
| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |
|
||||
| `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. |
|
||||
| Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. |
|
||||
|
||||
### Worker-token vs artifact-upload-token (delta)
|
||||
|
||||
| Property | Artifact-upload-token (today) | Worker-token (new) |
|
||||
|---|---|---|
|
||||
| Coverage | One route (`/runs/{id}/stages/{stageId}/artifacts`) | All 7 worker-touched run-scoped routes |
|
||||
| TTL | 24h | 72h |
|
||||
| Signing key | `OsRng` at server boot, in-memory only | HKDF from `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"` |
|
||||
| Survives server restart | No | Yes (up to natural expiry) |
|
||||
| Issuer string | `"fabro-server-artifact-upload"` | `"fabro-server-worker"` |
|
||||
| Scope claim | `"stage_artifacts:upload"` | `"run:worker"` |
|
||||
| Passed to worker | `--artifact-upload-token` argv | `FABRO_WORKER_TOKEN` env var |
|
||||
| Worker uses it as | Per-call method arg on the client | Client's bearer for every server call |
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- TTL: 72h. Refresh: none in-process; fresh mint at every spawn (start AND resume).
|
||||
- Key derivation: HKDF from `SESSION_SECRET` with context `b"fabro-worker-jwt-v1"`.
|
||||
- Replace artifact-upload-token entirely vs. keep both: replace.
|
||||
- Token transport: env var (`FABRO_WORKER_TOKEN`), not argv.
|
||||
- Revocation: none. Per-run binding + 72h `exp` is the entire blast-radius bound.
|
||||
- New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely.
|
||||
- Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain.
|
||||
- Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here.
|
||||
- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed.
|
||||
- Auth-failure exit codes: no — generic error handling.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact module name for new server-side worker-token machinery — likely `fabro-server/src/worker_token.rs`.
|
||||
- Mechanism for the compile-time "no `Display` for `Credential::Worker`" guard — `static_assertions::assert_not_impl_any!` is the natural fit; choose at implementation time.
|
||||
- Whether to enforce "worker module never imports `AuthStore`" structurally (clippy `disallowed_types` on the `commands::run` module). Nice-to-have; defer.
|
||||
- Core dump disable (`setrlimit(RLIMIT_CORE, 0)`) on the worker process. Same-UID attacker assumption holds today; defer.
|
||||
|
||||
## 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.*
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant Srv as fabro-server
|
||||
participant W as worker subprocess
|
||||
participant Child as sandbox stage child
|
||||
|
||||
Op->>Srv: start (SESSION_SECRET in env)
|
||||
Note over Srv: HKDF-derive WorkerTokenKeys<br/>context "fabro-worker-jwt-v1"
|
||||
Srv->>Srv: spawn scheduled (start or resume)
|
||||
Note over Srv: issue_worker_token(run_id)<br/>HS256 + claims{run_id, scope, 72h}
|
||||
Srv->>W: spawn with apply_worker_env (env_clear + allowlist)<br/>+ FABRO_WORKER_TOKEN injected
|
||||
W->>W: read FABRO_WORKER_TOKEN from env<br/>build Client with Credential::Worker(token)<br/>(no AuthStore, no OAuthSession)
|
||||
W->>Srv: POST /runs/{id}/events (Bearer ...)
|
||||
Srv->>Srv: authorize_run_scoped:<br/>1) try worker token (run_id match + exp check)<br/>2) else fall through to user-JWT extractor
|
||||
Srv-->>W: 200 OK
|
||||
W->>Child: spawn stage command via LocalSandbox::execute<br/>(env_clear + safelist; FABRO_WORKER_TOKEN excluded)
|
||||
Child-->>W: result (no token in env)
|
||||
Note over W,Srv: ... run completes ...
|
||||
Note over Srv: server restart: HKDF re-derives same key,<br/>outstanding tokens still verify (up to natural exp)
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [x] **Unit 1: Worker JWT primitives (claims, keys, mint)**
|
||||
|
||||
**Goal:** Server can mint a per-run worker JWT signed with a key derived from `SESSION_SECRET`. No callers yet.
|
||||
|
||||
**Requirements:** R2, R3.
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-server/src/worker_token.rs`
|
||||
- Modify: `lib/crates/fabro-server/src/auth/keys.rs` (add `derive_worker_jwt_key`)
|
||||
- Modify: `lib/crates/fabro-server/src/lib.rs` (module declaration)
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` (`AppState` field for `WorkerTokenKeys`, construction in `build_app_state`)
|
||||
- Test: `lib/crates/fabro-server/src/worker_token.rs` (unit tests inline)
|
||||
|
||||
**Approach:**
|
||||
- Constants: `WORKER_TOKEN_ISSUER = "fabro-server-worker"`, `WORKER_TOKEN_SCOPE = "run:worker"`, `WORKER_TOKEN_TTL_SECS = 72 * 60 * 60`.
|
||||
- `WorkerTokenClaims { iss, iat, exp, run_id, scope, jti }` — `jti` (random 128-bit hex) enables audit correlation in logs without exposing the token.
|
||||
- `WorkerTokenKeys { encoding, decoding, validation }` — built from a 32-byte HKDF output keyed by `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"`.
|
||||
- `pub fn issue_worker_token(keys: &WorkerTokenKeys, run_id: &RunId) -> Result<String, ApiError>` — `jsonwebtoken::encode` with HS256.
|
||||
- `pub(crate) fn derive_worker_jwt_key(secret: &[u8]) -> Result<[u8; 32], KeyDeriveError>` in `auth/keys.rs` — same HKDF construction as `derive_jwt_key`, distinct `info` parameter (`b"fabro-worker-jwt-v1"`). Mirrors the existing helper's error shape so the `KeyDeriveError` cases (empty / too-short secret) propagate identically.
|
||||
- **App-state construction wires it explicitly**: `build_app_state` resolves `SESSION_SECRET` (already required for the user-JWT key today), calls `derive_worker_jwt_key`, and bails with a clear startup error if it fails. Failure modes: missing `SESSION_SECRET`, secret too short. Add `worker_tokens: WorkerTokenKeys` field on `AppState` next to `artifact_upload_tokens` (the artifact field is deleted in Unit 3).
|
||||
- **Test app-state builders** (`worker_command_test_state` at `server.rs:7868` and any other test fixture that constructs `AppState`) must supply a fixture `SESSION_SECRET`. The existing test secret used by user-JWT tests can be reused.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server.rs:287-289, 319-326, 752-773, 798-812` (artifact-upload-token, end-to-end).
|
||||
- `auth/keys.rs:41` (`derive_jwt_key` HKDF construction).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `issue_worker_token` produces a token; `jsonwebtoken::decode` with the same `WorkerTokenKeys` returns the expected `WorkerTokenClaims` (iss, scope, run_id, jti).
|
||||
- Edge case: token issued with `WorkerTokenKeys` derived from secret S verifies under a *fresh* `WorkerTokenKeys` derived from the same S — proves restart survival (R3).
|
||||
- Edge case: token issued under secret S1 fails to verify under keys derived from secret S2 (rotation invalidation).
|
||||
- Edge case: derivation context label `b"fabro-worker-jwt-v1"` produces a key materially different from `derive_jwt_key(secret)` (no accidental cross-acceptance with user JWTs).
|
||||
- Error path: `derive_worker_jwt_key(b"")` returns `Err(KeyDeriveError::Empty)`. Mirrors existing `derive_jwt_key` error shape.
|
||||
- Error path: `derive_worker_jwt_key(short_secret)` returns `Err(KeyDeriveError::TooShort { .. })` for secrets below the minimum length.
|
||||
- Startup: `build_app_state` with no `SESSION_SECRET` in env returns a startup error matching the existing user-JWT-key startup-error wording.
|
||||
|
||||
**Verification:**
|
||||
- All worker-token unit tests pass.
|
||||
- `cargo build -p fabro-server` succeeds.
|
||||
- No production callers of new symbols yet — Unit 1 is purely additive infrastructure.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 2: Server `AuthorizeRunScoped` extractor family + client `Credential::Worker` variant**
|
||||
|
||||
**Goal:** Three typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`) accept worker token (run-id-bound) OR fall back to user JWT. Client crate gains a typed worker credential with no `Display` and redacted `Debug`.
|
||||
|
||||
**Requirements:** R2, R7.
|
||||
|
||||
**Dependencies:** Unit 1.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/worker_token.rs` (add `pub(crate) fn authorize_worker_token` internal helper; add the three public extractors `AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact` with `FromRequestParts` impls)
|
||||
- Modify: `lib/crates/fabro-server/src/lib.rs` (re-export the three extractors if needed by handler modules)
|
||||
- Modify: `lib/crates/fabro-client/src/credential.rs` (add `Worker(String)` variant — no `Display`)
|
||||
- Test: `lib/crates/fabro-server/src/worker_token.rs` (authorize helper tests inline)
|
||||
- Test: `lib/crates/fabro-client/src/credential.rs` (Debug + bearer_token tests inline)
|
||||
|
||||
**Approach:**
|
||||
- **Module placement & visibility**: `worker_token.rs` is a sibling module to `server.rs`; sibling modules cannot read private `AppState` fields. Two options: (a) keep the helper inside `impl AppState` in `server.rs` like `issue_artifact_upload_token` does today, or (b) put the helper in `worker_token.rs` and pass `&WorkerTokenKeys` directly (NOT `&AppState`). **Choose (b)** — keeps `AppState` internals private, makes the helper trivially testable without an `AppState` fixture, mirrors how `maybe_authorize_artifact_upload_token` already takes `&ArtifactUploadTokenKeys` not `&AppState`. The thin glue in `authorize_run_scoped` then takes `&AppState` and pulls `&state.worker_tokens` into the call.
|
||||
- `pub(crate) fn authorize_worker_token(parts: &Parts, run_id: &RunId, keys: &WorkerTokenKeys) -> Result<bool, ApiError>` — mirror `maybe_authorize_artifact_upload_token` (`server.rs:813-844`). Verification-first rule (no unverified claim peeking — `jsonwebtoken::decode` only returns claims after signature + expiry validation):
|
||||
- Bearer absent → `Ok(false)` silently.
|
||||
- `jsonwebtoken::decode` with `WorkerTokenKeys` returns `Err(_)` (any reason — bad signature, expired, malformed, alg mismatch) → `Ok(false)` silently. Could be a user JWT in fall-through, an expired worker token, or anything else; we don't know without verifying, and we don't peek at unverified payload bytes.
|
||||
- `decode` returns `Ok(claims)` AND `claims.scope != WORKER_TOKEN_SCOPE` → `Err(ApiError::forbidden())` + `tracing::warn!`. A token signed by us with a wrong scope is a misuse.
|
||||
- `decode` returns `Ok(claims)` AND `claims.run_id != run_id` → `Err(ApiError::forbidden())` + `tracing::warn!`. Cross-run reuse.
|
||||
- `decode` returns `Ok(claims)` AND scope + run_id match → `Ok(true)` + `tracing::info!`.
|
||||
- **Extractor body (shared logic)**: each `FromRequestParts` impl runs its path-parse step, then calls a shared `pub(crate)` helper that mirrors `authorize_artifact_upload` (`server.rs:846-854`): try `authorize_worker_token(parts, run_id, &state.worker_tokens)?`; if `Ok(false)`, fall through to `authenticate_service_parts(parts)`. `worker_tokens` stays `pub(crate)` on `AppState` so the helper in the same crate can read it.
|
||||
- `Credential::Worker(String)` — `bearer_token() -> &str` returns the string; `Debug` prints `Credential::Worker(<redacted>)`. **Do NOT implement `Display` on the `Credential` enum.** Compile-time hardening: assert `Credential: !Display` (variant-level assertions don't exist in Rust — the trait impl lives on the type).
|
||||
- **Audit logging at authorize time** (driven entirely by the verified-only rule above — no log if `decode` itself fails):
|
||||
- On successful worker-token auth: `tracing::info!(target = "worker_auth", run_id = %run_id, jti = %claims.jti, "worker token accepted")`.
|
||||
- On `Ok(claims)` with scope or run_id mismatch: `tracing::warn!(target = "worker_auth", reason = ..., jti = %claims.jti, "worker token rejected")`.
|
||||
- On `Err(_)` from `decode` (no claims available): silent. The bearer might be a user JWT in fall-through, an expired worker token, or garbage — we can't tell without verifying, and we don't try.
|
||||
- Never log the token string itself — only `jti`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server.rs:813-854` (artifact-upload-token authorize pattern).
|
||||
- `fabro-client/src/credential.rs:6-40` (existing `DevToken`/`OAuth` variants).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (server, unit): valid worker token for path run_id → `AuthorizeRunScoped` extractor succeeds; handler receives the parsed `RunId`.
|
||||
- Error path (server): worker token with `claims.run_id != path.run_id` → 403 + `worker_auth` warn with `jti`.
|
||||
- Error path (server): worker token signed with worker key but wrong scope → 403 + `worker_auth` warn.
|
||||
- Error path (server): expired worker token → `decode` returns `Err` → silent fall-through → user-JWT extractor rejects → 401. No `worker_auth` log.
|
||||
- Error path (server): bad signature (e.g. token signed with different key) → `decode` returns `Err` → silent fall-through → 401. No `worker_auth` log.
|
||||
- Error path (server): `alg=none` JWT → `decode` returns `Err` (validation requires HS256) → silent fall-through → 401.
|
||||
- Integration (server): no `Authorization` header → falls through to user-JWT extractor → 401 (no implicit acceptance).
|
||||
- Integration (server): valid user JWT, no worker token → user-JWT path accepts (R7).
|
||||
- Audit (precision): successful worker-token auth emits `target = "worker_auth"` info span with `run_id` and `jti`. Decode-success-but-claims-mismatch emits `warn` with `reason` and `jti`. Decode failure (any reason) emits NO `worker_auth` log — verify by counting log events on a user-JWT request and on an expired worker token; both must produce zero `worker_auth` lines.
|
||||
- Happy path (client): `Credential::Worker(s).bearer_token()` returns `s`.
|
||||
- Edge case (client): `Debug` impl prints `Credential::Worker(<redacted>)` — token string never appears in debug output.
|
||||
- Compile-time guard (client): assert `Credential: !Display` at the type level (e.g. via `static_assertions::assert_not_impl_any!(Credential: std::fmt::Display)`). Variants are not types; the trait impl lives on the enum.
|
||||
|
||||
**Verification:**
|
||||
- All extractor + helper tests pass with both branches exercised.
|
||||
- `cargo nextest run -p fabro-server -p fabro-client` succeeds.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 3: Wire worker-touched routes through the `AuthorizeRunScoped` extractor family; replace artifact-upload-token at the spawn**
|
||||
|
||||
**Goal:** Each of the 7 worker-touched routes accepts the worker token. Server spawn injects `FABRO_WORKER_TOKEN` env var. Old artifact-upload-token machinery deleted atomically. Lifecycle/admin/SSE routes are NOT touched and continue to require user JWT.
|
||||
|
||||
**Requirements:** R1, R2, R4, R7.
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs`
|
||||
- `get_run_state` (5076), `list_run_events` (5146), `append_run_event` (5096), `write_run_blob` (5352): replace `_auth: AuthenticatedService, Path(id): Path<String>` with `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors stay unchanged.
|
||||
- `read_run_blob` (5379): replace `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` with `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`.
|
||||
- `put_stage_artifact` (5838): replace `Path::<(String, String)>` + inline `authorize_artifact_upload(&parts, ...)` with `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact`. Keep `request: Request` for body extraction; continue to call `request.into_parts()` inside the handler for the header/body split.
|
||||
- `worker_command` (3701-3755): replace `state.issue_artifact_upload_token` → `state.issue_worker_token`. Drop the `--artifact-upload-token <jwt>` arg entirely. Set `cmd.env("FABRO_WORKER_TOKEN", token)` unconditionally (always, regardless of auth method). Also `cmd.env_remove("FABRO_WORKER_TOKEN")` before re-injection (defense against parent-env leakage). Delete the conditional `cmd.env("FABRO_DEV_TOKEN", token)` block (3740-3750) — `FABRO_WORKER_TOKEN` replaces it as the only authority-bearing re-injection.
|
||||
- **Lifecycle/admin/SSE routes left alone**: `cancel_run`, `pause_run`, `unpause_run`, `archive_run`, `unarchive_run`, `delete_run`, `submit_answer`, `start_run`, `create_run`, `attach_run_events` (`/runs/{id}/attach`), `attach_events` (`/attach`), and any list endpoint. Keep `_auth: AuthenticatedSubject` / `AuthenticatedService` directly. Add a one-line code comment at each of the 9 worker-rejecting handlers: `// Worker token intentionally not accepted; this is a user/admin action.`
|
||||
- Delete: `ARTIFACT_UPLOAD_TOKEN_*` constants (287-289), `ArtifactUploadClaims` (319-326), `ArtifactUploadTokenKeys` (313-317), `artifact_upload_token_keys` (798-812), `AppState::issue_artifact_upload_token` (752-773), `AppState::artifact_upload_tokens` field (565), `maybe_authorize_artifact_upload_token` (813-844), `authorize_artifact_upload` (846-854).
|
||||
- Test: existing tests in `lib/crates/fabro-server/src/server.rs` test module (rename / replace `worker_command_injects_dev_token_only_when_enabled` at 7836).
|
||||
|
||||
**Approach:**
|
||||
- The shape change is NOT trivially mechanical for JSON/body handlers like `append_run_event` (`Json<...>` body) or `write_run_blob` (`Bytes` body). Switching them to `Request::into_parts()` would require manually re-parsing the body. Instead, introduce a custom `FromRequestParts` extractor that composes naturally with body extractors.
|
||||
- **Route API is the extractor, not the helper.** Route handlers should use the new extractor types as their route-facing auth contract. `authorize_worker_token` and the inner decode/fall-through logic remain `pub(crate)` helpers used only by the extractors.
|
||||
- **Three extractor variants** (one per path shape the worker actually uses) in `worker_token.rs`:
|
||||
- `AuthorizeRunScoped(pub RunId)` — for `/runs/{id}/...` with a single run-id path param. Used by: `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`.
|
||||
- `AuthorizeRunBlob(pub RunId, pub RunBlobId)` — for `/runs/{id}/blobs/{blobId}`. Used by: `read_run_blob`.
|
||||
- `AuthorizeStageArtifact(pub RunId, pub StageId)` — for `/runs/{id}/stages/{stageId}/artifacts`. Used by: `put_stage_artifact` (and `list_stage_artifacts`, `get_stage_artifact` if they ever join the worker-touched set; not today).
|
||||
- Each `impl FromRequestParts` internally:
|
||||
- Extracts `Path::<(String, ...)>::from_request_parts` with the right tuple shape (1, 2, or 2 segments).
|
||||
- Parses each segment via the existing `parse_run_id_path`, `parse_run_blob_id_path`, `parse_stage_id_path` helpers.
|
||||
- Reads `AuthMode` from `parts.extensions` and `&AppState.worker_tokens` from axum state.
|
||||
- Calls `authorize_worker_token(parts, &run_id, &keys)?`; on `Ok(false)` falls through to `authenticate_service_parts(parts)`.
|
||||
- Returns the typed path params so handlers skip their own `Path<String>` + `parse_*` dance.
|
||||
- Handlers change:
|
||||
- `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`: `_auth: AuthenticatedService, Path(id): Path<String>` → `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors (`Json<...>`, `Bytes`) stay intact.
|
||||
- `read_run_blob`: `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` → `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`.
|
||||
- `put_stage_artifact`: currently takes `Request` + `Path::<(String, String)>`. Swap to `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact` + `request: Request` (body extraction stays manual via `request.into_parts()`).
|
||||
- Atomic swap, no transition period — cargo + tests catch any missed callsite.
|
||||
- **Pre-implementation audit:** grep all `client.*` and `api.*` callsites under `lib/crates/fabro-cli/src/commands/run/` (and any helpers it transitively uses) to confirm each resolves to one of the 7 endpoints in the table. If any newly-discovered handler exists, add a row and wire it through `AuthorizeRunScoped`.
|
||||
- **Structural rule:** `AuthorizeRunScoped` is used ONLY in handlers whose path contains `{id}` (`RunId`). Verify by grep: every usage must correspond to a `{id}` path segment.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `put_stage_artifact` handler (`server.rs:5838`) is the existing model: takes `parts: Parts`, calls `authorize_artifact_upload(&parts, &state, &id)?`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: each of the 5 newly-wired routes accepts a worker token whose `claims.run_id` matches the path `id` → expected response.
|
||||
- Error path: each route with worker token whose `claims.run_id` ≠ path `id` → 403.
|
||||
- Integration: each route with valid user JWT and no worker token → still works (R7 fall-through).
|
||||
- Replacement test for `worker_command_injects_dev_token_only_when_enabled`: rename to `worker_command_always_sets_worker_token_env`. Build `worker_command` for both `methods=["github"]` and `methods=["dev-token"]` settings; assert `FABRO_WORKER_TOKEN` env is set to a valid token in BOTH cases. Assert `FABRO_DEV_TOKEN` env is NOT set in either case. Assert no `--artifact-upload-token` or `--worker-token` arg appears in argv (env-only).
|
||||
- **Negative path (user-only route table):** for every route in the table below, assert presenting a valid worker token (with claims matching the run_id where applicable) is rejected. Tests MUST run under `AuthMode::Enabled` with a valid user-JWT key configured — under `AuthMode::Disabled`, `AuthenticatedService` accepts everything before any validation (`jwt_auth.rs:279`-style), so a "reject" assertion proves nothing. Model after the existing `jwt_auth.rs` tests that use `AuthMode::Enabled` with test secrets.
|
||||
|
||||
| Route | Handler | Expected status with worker token |
|
||||
|---|---|---|
|
||||
| `GET /runs` | `list_runs` | 401/403 |
|
||||
| `POST /runs` | `create_run` | 401/403 |
|
||||
| `GET /runs/resolve` | `resolve_run` | 401/403 |
|
||||
| `POST /preflight` | `run_preflight` | 401/403 |
|
||||
| `POST /graph/render` | `render_graph_from_manifest` | 401/403 |
|
||||
| `GET /attach` | `attach_events` | 401/403 |
|
||||
| `GET /boards/runs` | `list_board_runs` | 401/403 |
|
||||
| `GET /runs/{id}` | `get_run_status` | 401/403 |
|
||||
| `DELETE /runs/{id}` | `delete_run` | 401/403 |
|
||||
| `GET /runs/{id}/questions` | `get_questions` | 401/403 |
|
||||
| `POST /runs/{id}/questions/{qid}/answer` | `submit_answer` | 401/403 |
|
||||
| `GET /runs/{id}/attach` | `attach_run_events` | 401/403 |
|
||||
| `GET /runs/{id}/checkpoint` | `get_checkpoint` | 401/403 |
|
||||
| `POST /runs/{id}/cancel` | `cancel_run` | 401/403 |
|
||||
| `POST /runs/{id}/start` | `start_run` | 401/403 |
|
||||
| `POST /runs/{id}/pause` | `pause_run` | 401/403 |
|
||||
| `POST /runs/{id}/unpause` | `unpause_run` | 401/403 |
|
||||
| `POST /runs/{id}/archive` | `archive_run` | 401/403 |
|
||||
| `POST /runs/{id}/unarchive` | `unarchive_run` | 401/403 |
|
||||
| `GET /runs/{id}/graph` | `get_graph` | 401/403 |
|
||||
| `GET /runs/{id}/stages` | `list_run_stages` | 401/403 |
|
||||
| `GET /runs/{id}/artifacts` | `list_run_artifacts` | 401/403 |
|
||||
| `GET /runs/{id}/files` | `list_run_files` | 401/403 |
|
||||
| `GET /runs/{id}/stages/{stageId}/artifacts` | `list_stage_artifacts` | 401/403 |
|
||||
| `GET /runs/{id}/stages/{stageId}/artifacts/download` | `get_stage_artifact` | 401/403 |
|
||||
| `GET /runs/{id}/billing` | `get_run_billing` | 401/403 |
|
||||
| `GET /runs/{id}/settings` | `get_run_settings` | 401/403 |
|
||||
| `POST /runs/{id}/preview` | `generate_preview_url` | 401/403 |
|
||||
| `POST /runs/{id}/ssh` | `create_ssh_access` | 401/403 |
|
||||
| `GET /runs/{id}/sandbox/files` | `list_sandbox_files` | 401/403 |
|
||||
| `GET /runs/{id}/sandbox/file`, `PUT /runs/{id}/sandbox/file` | `get_sandbox_file`, `put_sandbox_file` | 401/403 |
|
||||
|
||||
Every route in the real-routes router (`server.rs:1162-1230`) except the 7 worker-touched routes is user-only. The acceptance criterion is a single test helper that iterates this explicit table and asserts rejection for each under `AuthMode::Enabled`. Routes that return `not_implemented` (turns, workflows, insights) are not included — they'll stay user-only automatically if ever implemented; flag in a follow-up if needed.
|
||||
- **Positive path for `put_stage_artifact`** (auth semantics changed — the only worker-touched route that previously had its own auth helper): explicit tests for the artifact upload route.
|
||||
- Valid worker token with matching `run_id` → 200 (octet-stream variant; multipart variant if cheap to set up).
|
||||
- Worker token with `claims.run_id != path.run_id` → 403.
|
||||
- Valid user JWT (no worker token) → 200 (R7 fall-through preserved on this route).
|
||||
- No bearer at all → 401.
|
||||
- Regression (env scrubbing): extend the existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) to assert `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` don't leak (alongside the existing `SESSION_SECRET` assertion).
|
||||
- Edge case: assert deleted symbols (`ArtifactUploadClaims`, `authorize_artifact_upload`, etc.) no longer exist — covered implicitly by `cargo build`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build --workspace` succeeds (no references to deleted artifact-upload-token symbols).
|
||||
- `cargo nextest run -p fabro-server` passes.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 4: Worker (CLI) — use injected worker token from env, stop reading `AuthStore`**
|
||||
|
||||
**Goal:** Worker subprocess reads its credential from `FABRO_WORKER_TOKEN` env at startup, uses it as its sole bearer for every server call, and never constructs `AuthStore::default()`. The artifact uploader holds the same token string in a per-call bearer field (the client's upload methods require a per-call bearer argument today).
|
||||
|
||||
**Requirements:** R1, R5.
|
||||
|
||||
**Dependencies:** Unit 2 (`Credential::Worker` variant), Unit 3 (server sets `FABRO_WORKER_TOKEN` env on the worker subprocess).
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/args.rs:808` — DELETE the `artifact_upload_token: Option<String>` field on `RunWorkerArgs`. Do NOT add a replacement clap arg — the worker reads from env directly.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs:74-90` — drop the `artifact_upload_token` plumbing on the dispatch path.
|
||||
- Modify: `lib/crates/fabro-cli/src/server_client.rs` — add `pub(crate) async fn connect_server_target_with_bearer(target: &ServerTarget, bearer: &str) -> Result<Client>`. Builds `Client` with `.credential(Credential::Worker(bearer.to_owned()))`, no `oauth_session`, no `resolve_target_credential` call, no `AuthStore` access.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs`
|
||||
- At top of `execute`: read `FABRO_WORKER_TOKEN` from env via `std::env::var`. Validate non-empty; bail with a clear error mentioning `FABRO_WORKER_TOKEN` if missing or empty (this is a server-bug indicator, not a user error). Drop `artifact_upload_token` from `execute`'s signature.
|
||||
- Line 67: replace `connect_server_target_direct(&server)` with `connect_server_target_with_bearer(&target, &worker_token)`.
|
||||
- Delete `MissingArtifactUploadTokenUploader` (300-317) and the `match artifact_upload_token { Some/None }` fork (~244-251); always construct `HttpArtifactUploader`.
|
||||
- `HttpArtifactUploader`: **keep** the per-call bearer field, rename `bearer_token: String` → `worker_token: String`. The client methods `upload_stage_artifact_file` and `upload_stage_artifact_batch` still require a per-call bearer parameter (no `Client::credential()` accessor exists today — see next bullet), so the uploader must hold the token and pass it per call. The token is the same string stored at client construction in `Credential::Worker`.
|
||||
- Keep `lib/crates/fabro-client/src/client.rs:1043, 1081` — `upload_stage_artifact_*` continue to take a `bearer_token` parameter. There is no `Client::credential()` accessor today (`credential` at `client.rs:176` is a builder setter, not a getter), so threading the token per-call is the path of least resistance. The worker-side caller passes the same `FABRO_WORKER_TOKEN` string it built the client with. (If a `Client::credential()` accessor is added later as a separate concern, the per-call parameter can be dropped then.)
|
||||
|
||||
**Stage-execution env scrubbing (narrow scope):**
|
||||
|
||||
The spawn site that runs user-supplied workflow stage commands must NOT see `FABRO_WORKER_TOKEN`: `LocalSandbox::execute` (`lib/crates/fabro-sandbox/src/local.rs:221`). It already does `env_clear` + safelist (`local.rs:43-66`) with a `_token` suffix denylist that incidentally catches `FABRO_WORKER_TOKEN`.
|
||||
|
||||
- Modify: `lib/crates/fabro-sandbox/src/local.rs:43-66` — add `"FABRO_WORKER_TOKEN"` (and `SESSION_SECRET`, `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) to an explicit denylist alongside the suffix heuristic. Comment why: future rename like `FABRO_WORKER_AUTH` would silently leak under the suffix heuristic alone.
|
||||
- **Critical: filter both inherited env AND explicit `env_vars` extras.** `LocalSandbox::execute` (`local.rs:224`) appends caller-supplied `env_vars` after the inherited-env filter, so a stage config with `env_vars` containing `FABRO_WORKER_TOKEN` would still leak. Apply the same denylist to the `env_vars` extras path: drop any key in the denylist before calling `cmd.env(key, value)` on each extra.
|
||||
|
||||
**Hooks (host mode) — surgical scrub:**
|
||||
|
||||
`fabro-hooks/src/executor.rs:186` runs `sh -c <hook command>` for non-sandbox hooks and inherits the worker process env. Even though hooks are operator-configured (not random user input), they are still shell commands that have no business reading `FABRO_WORKER_TOKEN`.
|
||||
|
||||
- Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env.
|
||||
|
||||
**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.
|
||||
|
||||
**Approach:**
|
||||
- `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged.
|
||||
- The new constructor is the *only* path the worker takes; verify by grep that `commands::run::runner` is the only module importing it.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server_client.rs:80-110` (`connect_managed_unix_socket_api_client_bundle`) for the client-builder shape; new constructor is a stripped-down version.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `connect_server_target_with_bearer` builds a `Client` whose outgoing requests carry `Authorization: Bearer <worker-token>`.
|
||||
- Edge case: `connect_server_target_with_bearer` does NOT call `AuthStore::default()` — verify by injecting a `FABRO_AUTH_FILE=/nonexistent` env override and confirming construction succeeds (the helper must not even attempt to read the file).
|
||||
- Edge case: `connect_server_target_with_bearer` does NOT install an `OAuthSession` (no refresh attempts on 401).
|
||||
- Integration: `runner::execute` with `FABRO_WORKER_TOKEN=<jwt>` set in env POSTs an event using only the injected token; no fallback to `auth.json`.
|
||||
- Error path: `runner::execute` invoked without `FABRO_WORKER_TOKEN` in env returns a clear error mentioning the variable name; does NOT silently fall back to user OAuth.
|
||||
- Edge case: `RunWorkerArgs` no longer has `artifact_upload_token` field — `cargo build` confirms.
|
||||
- Integration (env hygiene, sandbox path, **inherited**): worker env has `FABRO_WORKER_TOKEN=<jwt>` set; a workflow stage Bash command `env | grep -E "FABRO_WORKER_TOKEN|SESSION_SECRET|FABRO_JWT_PRIVATE_KEY"` prints empty output. Covers `LocalSandbox::execute` denylist correctness for inherited env.
|
||||
- Integration (env hygiene, sandbox path, **explicit env_vars**): a workflow stage configured with `env_vars: { FABRO_WORKER_TOKEN: "leaked", MY_VAR: "ok" }` produces a child env where `FABRO_WORKER_TOKEN` is absent but `MY_VAR=ok` is present. Covers the explicit-extras filter path. **Without this test, the explicit-env_vars bypass is undetected.**
|
||||
- Integration (env hygiene, hooks): a host-mode hook (`fabro-hooks/src/executor.rs:186`) spawned with `FABRO_WORKER_TOKEN` in the worker's env produces a child where `env | grep FABRO_WORKER_TOKEN` is empty.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build --workspace` succeeds.
|
||||
- `cargo nextest run -p fabro-cli` passes.
|
||||
- grep for `AuthStore` from `commands/run/` returns no hits.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 5: Stamp `system:worker` actor on worker-emitted events (worker-side sink wrapper)**
|
||||
|
||||
**Goal:** Events the worker emits without a typed actor get a `system:worker` stamp at the *worker-side sink layer*. User identity stays on the run record (`RunSpec.provenance.subject`), where it already lives.
|
||||
|
||||
**Requirements:** R6.
|
||||
|
||||
**Dependencies:** Should land WITH the auth changes (Units 1-4), not in isolation. Landing alone produces a wire-visible behavior change (worker events flip from `actor: None` to `actor: System(worker)`) without the auth context that justifies it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-workflow/src/event.rs` — add `RunEventSink::Map { transform, inner }` variant that applies `transform` to each event before forwarding to `inner`. Wire it into the existing dispatch logic so all events reach the inner sink already transformed.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs` — at the `RunEventSink::fanout([...])` construction site (`runner.rs:100`), wrap the fanout in `RunEventSink::Map { transform: stamp_system_worker, inner: ... }` so the stamp applies to all downstream sinks (backend HTTP, local callback, future).
|
||||
- Test: `lib/crates/fabro-workflow/src/event.rs` (test the `Map` variant in isolation).
|
||||
- Test: `lib/crates/fabro-cli/src/commands/run/runner.rs` (test the worker-local stamp wrapper end-to-end).
|
||||
|
||||
**Approach:**
|
||||
- **Critical: do NOT modify `lib/crates/fabro-workflow/src/event.rs::to_run_event_at` or `stored_event_fields`.** Those helpers are shared with the server (e.g. `server.rs:6702` flushes lifecycle events through `workflow_event::to_run_event`). Default-filling there mis-stamps server-emitted events.
|
||||
- Helper function (not `const` — `ActorRef::id`/`display` are `Option<String>`, heap-allocated): `fn system_worker_actor() -> ActorRef { ActorRef { kind: ActorKind::System, id: Some("worker".to_string()), display: Some("system:worker".to_string()) } }` lives in `runner.rs` (worker-local).
|
||||
- **Stamping must apply to the whole fanout, not just one sink variant.** `RunEventSink` is an enum (`Backend | Callback | Composite | …`) in `fabro-workflow/src/event.rs`. Wrapping only the `Backend` variant means the local callback (today: `update_worker_title_from_event`) and any future sink see unstamped events.
|
||||
- **Design: add `RunEventSink::Map { transform: Arc<dyn Fn(RunEvent) -> RunEvent + Send + Sync>, inner: Box<RunEventSink> }`** variant. Worker constructs `RunEventSink::Map { transform: stamp_system_worker, inner: Box::new(RunEventSink::fanout([backend, callback])) }`; stamp applies before the fanout splits.
|
||||
- **Dispatch: non-recursive, iterative, owned per branch.** The existing `write_run_event` at `event.rs:2698` uses an iterative stack with a single shared `&RunEvent`. Naively translating `Map => inner.write_run_event(&mapped).await` would introduce recursive async (doesn't compile cleanly without boxing each recursive call, and obscures the shape).
|
||||
- Redesign the traversal to carry **`(sink, owned_event)` pairs** on the stack instead of `(&sink, &event)`. Each node owns the `RunEvent` value for its subtree.
|
||||
- `Map { transform, inner }`: apply `transform` to the owned event → push `(*inner, transformed_event)` onto the stack. The branch downstream sees the new event; the original is dropped when this stack frame unwinds.
|
||||
- `Composite { sinks }`: for each child sink, push `(child, event.clone())`. Each branch gets its own owned event. (Cloning `RunEvent` is cheap — it's a struct of owned data already serialized once; no deep-copy of large payloads.)
|
||||
- `Backend { ... }` / `Callback { ... }`: terminal — invoke with the owned event, no recursion.
|
||||
- Keep the existing async-loop shape; only the stack element type changes.
|
||||
- The transform applies the value-based rule: **if `event.actor.is_none()`, fill with `system_worker_actor()`**. Agent events (`AssistantMessage`) keep `ActorKind::Agent` because `actor.is_some()`. Worker self-cancel events (`Event::RunCancelRequested { actor: None }`) correctly get the system actor.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `RunEventSink::fanout` composition (`fabro-workflow/src/event.rs` sink layer).
|
||||
- Existing actor-stamping for lifecycle events at the server endpoints (`actor_from_subject` at `server.rs:6175`) — server-side stamping for user actions; worker-side wrapper for worker events.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (`Map` variant): a `RunEventSink::Map { transform: |e| e.with_actor(ActorRef::user("alice")), inner: backend }` applied to an event with any actor → forwarded event has actor = `ActorRef::user("alice")` regardless. Confirms the variant works.
|
||||
- Happy path (worker stamp): a stage-execution `RunEvent { actor: None, ... }` enters the wrapped fanout → BOTH the backend sink AND the local callback see `actor: Some(ActorRef { kind: System, id: Some("worker"), display: Some("system:worker") })`.
|
||||
- Edge case: `RunEvent { actor: Some(user_actor), ... }` → both sinks retain the user actor (`actor.is_some()` → no-op).
|
||||
- Edge case: agent message `RunEvent { actor: Some(ActorKind::Agent), ... }` → both sinks retain `ActorKind::Agent`.
|
||||
- Edge case: worker self-cancel `RunEvent { actor: None, body: RunCancelRequested { ... } }` → both sinks get `system:worker`.
|
||||
- Per-sink uniformity assertion: construct the worker's actual fanout (Backend + Callback), feed an `actor: None` event in, capture what each sink receives, assert both have `system:worker`. Without this test, only stamping the Backend variant could regress without detection.
|
||||
- Regression: server-side event flush via `workflow_event::to_run_event` (`server.rs:6702`) is unchanged — `to_run_event_at` retains its passthrough semantics.
|
||||
- Regression: `create_hydrates_provenance_into_store_state` (`fabro-workflow/src/operations/create.rs`) still passes — originator user identity still on `RunSpec.provenance.subject`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli` passes.
|
||||
- New tests for the default-fill and the override-protections both pass.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 6: End-to-end regression — github-only worker run with no `~/.fabro/auth.json`**
|
||||
|
||||
**Goal:** Lock in the bug fix: a github-only deployment can spawn a worker that completes a run, with no user OAuth artifact present on the worker host. Worker authenticates using only `FABRO_WORKER_TOKEN`.
|
||||
|
||||
**Requirements:** R1, R5.
|
||||
|
||||
**Dependencies:** Units 1-5.
|
||||
|
||||
**Files:**
|
||||
- Create: an integration test under `lib/crates/fabro-cli/tests/it/cmd/` (existing test scaffolding) — file name per local convention (e.g. `worker_auth.rs`).
|
||||
|
||||
**Approach:**
|
||||
- **Test fixture:**
|
||||
- Server configured with `auth.methods = ["github"]` only; no `FABRO_DEV_TOKEN` in `server.env`.
|
||||
- `FABRO_HOME` redirected to a fresh tempdir on the *worker* side (no `auth.json`, no `dev-token` file).
|
||||
- Submitter path uses an authenticated test user JWT (minted directly via `auth/jwt.rs::issue` with the test `SESSION_SECRET`) to call `POST /runs` and start the run. **Do not** confuse this with the worker's auth — the user JWT is what authorizes run creation; the worker JWT (server-issued in response) is what the worker subprocess uses.
|
||||
- Run a tiny workflow end-to-end via the daemon → worker spawn path.
|
||||
- Assert the worker successfully POSTs at least one `RunEvent` and the run reaches a terminal status.
|
||||
- Assert `~/.fabro/auth.json` is not touched (non-existence at the redirected `FABRO_HOME`).
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing integration tests in `lib/crates/fabro-cli/tests/it/cmd/` (per `support.rs` helpers like `daemon.bind.to_target()`).
|
||||
- CLAUDE.md note: tests must use `.no_proxy()` HTTP clients.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: github-only server + no `auth.json` on worker host + minimal workflow → run completes successfully; events visible via `GET /runs/{id}/events`.
|
||||
- Integration: same setup but worker spawned with a deliberately-bogus `FABRO_WORKER_TOKEN` (e.g. valid HS256 but wrong `run_id` claim) → worker fails fast on first server call.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it` passes the new test.
|
||||
- Test fails on `main` (pre-Units 1-5) — confirms it covers the regression.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Worker subprocess no longer reads `~/.fabro/auth.json`. CLI user-facing commands (`fabro run`, `fabro ps`, `fabro auth`, etc.) unchanged — they still go through `connect_server_target` / `connect_server_with_settings`. SSE attach endpoints unchanged: worker is a producer, not a consumer; user-JWT-only auth on those routes preserved.
|
||||
- **Error propagation:** Worker token expiry mid-run → next server call returns 401, worker exits with a generic error, server marks run failed via existing pump-worker exit handling at `server.rs:4786`. No new error class.
|
||||
- **State lifecycle:** Server restart with HKDF-derived key: outstanding worker tokens remain valid up to natural expiry. Server restart with `SESSION_SECRET` rotated: outstanding workers fail at next call (acceptable; matches user-session invalidation). No revocation set.
|
||||
- **Event sink uniformity:** the worker wraps its `RunEventSink::fanout([Store(http), Callback])` (`runner.rs:100`) in `RunEventSink::Map { transform: stamp_system_worker, inner: fanout }`, so the stamp applies once *before* the fanout splits — both the HTTP backend and the local callback observe identical actor metadata. The shared `to_run_event_at` converter remains pure (passthrough). A test asserts per-sink uniformity directly.
|
||||
- **API surface parity:** `RunEvent.actor` shape unchanged (`ActorRef` already has `ActorKind::System`); worker-emitted events newly carry `system:worker` instead of `None`. Web UI audit (`apps/fabro-web/app/`) found ZERO references to `actor` or `author` today — nothing to break.
|
||||
- **Worker-process trust degradation:** A workflow stage that compromises the worker process (malicious shell, code injection) gains read access to `FABRO_WORKER_TOKEN` for the worker's lifetime + up to 72h until natural expiry. Blast radius bounded by run-id claim: only the compromised run's blobs/events/state are accessible. Not cross-run. `SESSION_SECRET` is NOT in the worker's env (`apply_worker_env` allowlist) so the worker cannot mint cross-run tokens.
|
||||
- **Integration coverage:** Unit 6 covers github-only-no-auth-store regression; existing CLI integration tests cover dev-token deployments.
|
||||
- **Unchanged invariants:** End-user auth (dev-token / github) unchanged. `RunAuthMethod` enum unchanged. `RunSpec.provenance` shape unchanged. Webhook auth unchanged. Lifecycle/admin/SSE/list endpoints continue to require user auth — worker token explicitly rejected on all of them. `OpenAPI` / `fabro-api-client` (TypeScript) DTOs unchanged.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Worker process inherits `SESSION_SECRET` → can mint tokens for any run, defeating per-run binding | **Already structurally mitigated**: `apply_worker_env` at `spawn_env.rs:18` does `env_clear` + 8-name allowlist that excludes `SESSION_SECRET`. Existing `worker_allowlist_is_fail_closed` test asserts this. Unit 3 extends the test to also cover `FABRO_JWT_*` and `GITHUB_APP_*`. |
|
||||
| Token leaks via `format!`/`Display`/`tracing` of `Credential::Worker` payload | `Credential::Worker` has redacted `Debug`, no `Display`. Compile-time guard test in Unit 2 asserts `!impl Display`. Audit logging in Unit 2 logs `jti` only, never the token. |
|
||||
| Sentry / panic capture serializes the worker's env or backtrace locals | Sentry panic hook (`fabro-telemetry/src/panic.rs`) captures only panic message + stacktrace, not env or frame variables. Code review responsibility to keep token out of panic format strings. |
|
||||
| 72h token compromised mid-run, attacker uses it from any host on network | Run-id binding limits blast radius to one run. No revocation; rotating `SESSION_SECRET` is the only invalidation mechanism (also invalidates user sessions). Acceptable for current threat model. |
|
||||
| `FABRO_WORKER_TOKEN` readable via `/proc/<pid>/environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. |
|
||||
| Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. |
|
||||
| Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. |
|
||||
| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |
|
||||
| `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. |
|
||||
| Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. |
|
||||
| Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- `docs-internal/` — if any internal doc describes worker auth (search before landing), update to reflect: "worker → server auth uses a server-issued per-run JWT, independent of end-user auth method."
|
||||
- No external user-facing doc impact (no public API change; CLI args on `__run-worker` are internal-only, hidden via `#[command(hide = true)]`).
|
||||
|
||||
### Deploy story (greenfield, atomic swap)
|
||||
|
||||
No shipped deployments to preserve. Atomic swap:
|
||||
1. Deploy new binary; server restarts.
|
||||
2. New runs spawn workers with `FABRO_WORKER_TOKEN` in env; worker uses it via `Credential::Worker`.
|
||||
3. Old artifact-upload-token mechanism is gone from the codebase entirely.
|
||||
|
||||
No drain, no shim, no checklist beyond verifying `SESSION_SECRET` is set (HKDF key derives from it).
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Worker auth surface inventory: `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127`
|
||||
- Artifact-upload-token model to replace: `lib/crates/fabro-server/src/server.rs:287-289, 752-854`
|
||||
- Worker spawn site: `lib/crates/fabro-server/src/server.rs:3701-3755`
|
||||
- Existing HKDF key derivation: `lib/crates/fabro-server/src/auth/keys.rs:41`
|
||||
- Existing worker env allowlist: `lib/crates/fabro-server/src/spawn_env.rs:18`
|
||||
- `Credential` variants: `lib/crates/fabro-client/src/credential.rs:6-30`
|
||||
- `ActorRef` / `ActorKind`: `lib/crates/fabro-types/src/run_event/mod.rs:29-81`
|
||||
- `RunProvenance`: `lib/crates/fabro-types/src/run.rs:34-49`
|
||||
- Stage-execution chokepoint: `lib/crates/fabro-sandbox/src/local.rs:221, 43-66`
|
||||
- Coordinated plans: `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md`, `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md`, `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md`
|
||||
- Origin of artifact-upload-token pattern: `docs/plans/2026-04-06-object-backed-artifact-uploads.md:42-45`
|
||||
- Worker subprocess history: `docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md`, `docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md`
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
---
|
||||
title: "refactor: collapse settings resolve indirection layer"
|
||||
type: refactor
|
||||
status: completed
|
||||
date: 2026-04-23
|
||||
---
|
||||
|
||||
# refactor: collapse settings resolve indirection layer
|
||||
|
||||
## Overview
|
||||
|
||||
Delete `fabro_config::Resolver`, the per-`*Settings` `*_into(&mut errors)` methods, the private `ResolvedSettingsTree` in `fabro-workflow`, and the `resolve_settings_tree` free fn. Add a `WorkflowSettings` sibling to `ServerSettings`/`UserSettings` so workflow ops have one entrypoint. Standalone `resolve_*_from_file` helpers stay (single-namespace consumers depend on them) but get rewritten to not go through `Resolver`.
|
||||
|
||||
`WorkflowSettings` is **workflow-only**: `{ project, workflow, run }`. It does NOT hold `server` or `features` — even though the per-request layer DOES carry `features` and a subset of `server` (storage, scheduler, artifacts, web, api) per `materialize_settings_layer`. Excluding them is an **ownership choice**: we want the live `ServerSettings` (resolved at server boot) to remain the single authority for deployment-level config, and we don't want a workflow-side parallel copy that can quietly drift. `ServerSettings` and `UserSettings` keep `features` because they're constructed from a complete owned settings layer at boot — they ARE the authority.
|
||||
|
||||
Today's lifted `server_storage_root` field on `ResolvedSettingsTree` was an asymmetric workaround for "create_run needs one server-ops field but has no access to live `ServerSettings`." The fix: `create_run` gains a `storage_root: PathBuf` parameter so the caller (which owns the live `ServerSettings`) supplies it. No more lifted server fields anywhere.
|
||||
|
||||
PR 2 (Combine trait + derive, uv pattern) is a separate, larger refactor — instructions in `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md`.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Today three layers sit between `SettingsLayer` and consumer code:
|
||||
|
||||
1. `Resolver` (introduced in 52d24531d) — caches `apply_builtin_defaults` across multiple per-namespace resolves.
|
||||
2. `*_into(&mut errors)` methods on `Resolver` — let `ServerSettings::from_layer` and `UserSettings::from_layer` accumulate errors across two namespaces.
|
||||
3. `resolve_settings_tree` + `ResolvedSettingsTree` in `fabro-workflow/src/operations/create.rs` — bundles four namespaces for `create_run`.
|
||||
|
||||
`Resolver` exists only because `create_run` was running 4× `apply_builtin_defaults(file.clone())` per request. It's a perf micro-fix dressed up as an API primitive. `ResolvedSettingsTree` is the missing `WorkflowSettings` sibling of `ServerSettings`/`UserSettings` — wrong crate, wrong name, asymmetric shape (lifts `storage_root: InterpString` instead of holding a full `ServerNamespace`).
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Single entrypoint per consumer (Server/User/Workflow), each with `from_layer(&SettingsLayer) -> Result<...>`.
|
||||
- R2. Multi-namespace error accumulation preserved — `*Settings::from_layer` must surface errors from ALL its namespaces, not first-only.
|
||||
- R3. Standalone `resolve_*_from_file` helpers continue to work for single-namespace consumers (dozens of callers across the workspace).
|
||||
- R4. **`WorkflowSettings` holds workflow-owned namespaces only** (`project`, `workflow`, `run`). `server` and `features` are excluded as an ownership choice — the live `ServerSettings` is the single authority for deployment-level config, even though `materialize_settings_layer` does flow `features` and a subset of `server.*` (storage, scheduler, artifacts, web, api) through into the per-request layer. `ServerSettings` and `UserSettings` are constructed at boot from a complete owned settings layer and hold every namespace their consumer reads. Server-ops fields needed by `create_run` (today: just `storage.root`) come from the caller as parameters, not from the bundle.
|
||||
- R5. No change to resolved-settings semantics or to persisted `Event::RunCreated.settings` shape. **`record.run_dir` is now derived from the new `storage_root` parameter, not from `request.settings.server.storage.root` as today.** For the production HTTP caller (which passes the same resolved `server.storage.root` it would have read from the live `ServerSettings`), `record.run_dir` matches today's value. For direct `create()` callers (test helpers, future API consumers) that pass a different path, `record.run_dir` will reflect the parameter — that is the new invariant. The other consumer-visible changes are: (a) `create_run`'s signature gains one parameter (R7); (b) the storage-root interpolation-failure error path moves from workflow-side to transport-side with different error text — see Risks.
|
||||
- R6. **Workflow-settings resolution errors preserve `render_resolve_errors` formatting** (today: `; `-joined). `WorkflowSettings::from_layer` returns `Result<Self, Vec<ResolveError>>` (matching the `resolve_*_from_file` convention) so `create_run` keeps formatting via `render_resolve_errors`. R6 covers ONLY the `resolve_*` failure path inside `from_layer` — not the storage-root interpolation failure, which moves out of `create_run` per R5.
|
||||
- R7. `create_run`'s signature gains a `storage_root: PathBuf` parameter. Callers supply it from the live deployment context: `fabro-server` reads it from its boot-time `ServerSettings`; tests pass a fixture path.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Out of scope: the Combine refactor (PR 2).
|
||||
- Out of scope: migrating every `resolve_*_from_file` caller to a bundle. Standalone helpers stay; only `create_run` gets the bundle treatment.
|
||||
- Out of scope: removing `render_resolve_errors` (used by 4+ callers; orthogonal).
|
||||
- Out of scope: changing the `*Layer` / `*Namespace` types in `fabro-types`.
|
||||
- Out of scope: changing `apply_builtin_defaults`, `defaults.toml`, or any per-namespace `resolve_*(layer, &mut errors)` function body.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `lib/crates/fabro-config/src/context.rs` — current `ServerSettings` and `UserSettings` (use `Resolver`).
|
||||
- `lib/crates/fabro-config/src/resolve/resolver.rs` — entire file deletes.
|
||||
- `lib/crates/fabro-config/src/resolve/mod.rs` — `resolve_storage_root` + 6× `resolve_*_from_file` helpers (today: thin wrappers over `Resolver`; rewrite to inline `apply_builtin_defaults` + per-namespace resolve).
|
||||
- `lib/crates/fabro-config/src/resolve/{cli,server,project,features,run,workflow}.rs` — per-namespace `resolve_*` fns; bodies unchanged. Visibility stays `pub` (callers in tests + helper crates).
|
||||
- `lib/crates/fabro-config/src/defaults.rs` — `apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer`. One clone per call.
|
||||
- `lib/crates/fabro-workflow/src/operations/create.rs:63-68` — `ResolvedSettingsTree`; `:116` use site; `:166` `combined_labels` call; `:287-297` `resolve_settings_tree`; `:299-304` `combined_labels` fn.
|
||||
- `lib/crates/fabro-config/src/resolve/server.rs:68-75` — `resolve_storage` already substitutes `default_storage_dir()` when `server.storage.root` is missing. `Resolver::storage_root()` was duplicating this. With `server` excluded from `WorkflowSettings`, the duplication moves: the single authority for storage_root is now whatever `ServerSettings::server.storage.root` resolves to at server boot (used by the existing `state.server_storage_dir()` helper).
|
||||
- `lib/crates/fabro-server/src/server.rs:577,643,659` — `AppState.settings` is a raw `Arc<RwLock<SettingsLayer>>`; `AppState.server_settings` is a separate `RwLock<Arc<ServerSettings>>` accessed via `state.server_settings()` (line 643). There IS a `state.server_storage_dir() -> PathBuf` helper (line 659), **but it panics on interpolation failure** (`.expect("server storage root should be resolved at startup")`). Today's `create()` returns `Error::Precondition` on the same failure (`create.rs:118-126`). Unit 3 must NOT use `server_storage_dir()` — the HTTP handler has to do its own `resolve_interp_string` and map errors to `ApiError`. See Unit 3 Approach.
|
||||
- `lib/crates/fabro-config/src/effective_settings.rs:55-64` — documents that `server_settings.features` AND a small subset of `server_settings.server` (storage, scheduler, artifacts, web, api) ARE applied authoritatively into the per-request layer. Server-ops fields (auth, listen, ip_allowlist, slatedb, logging, integrations) are stripped. So the per-request layer DOES carry storage and features — `WorkflowSettings` excludes them by **ownership choice** (we want the live `ServerSettings` to be the single authority for deployment-level config), not by capability constraint.
|
||||
|
||||
### Call Site Audit (verified)
|
||||
|
||||
`resolve_*_from_file` standalones — KEEP (dozens of callers):
|
||||
- `resolve_run_from_file`: 20+ callers across fabro-cli, fabro-server, fabro-workflow, tests
|
||||
- `resolve_server_from_file`: 12+ callers across fabro-cli, fabro-server, fabro-install, tests
|
||||
- `resolve_features_from_file`: `fabro-server/src/server.rs:1364`
|
||||
- `resolve_project_from_file` / `resolve_workflow_from_file` / `resolve_cli_from_file`: callers in `fabro-config/src/project.rs`, fabro-cli, tests
|
||||
- `resolve_storage_root`: 1 external caller (`fabro-cli/src/local_server.rs:15`) + tests
|
||||
|
||||
`Resolver` — DELETE (1 caller):
|
||||
- `lib/crates/fabro-workflow/src/operations/create.rs:288` (in `resolve_settings_tree`)
|
||||
|
||||
`ResolvedSettingsTree` / `resolve_settings_tree` / `combined_labels` (free fn) — DELETE (private to `create.rs`, all uses local).
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
None directly applicable. The original `Resolver` commit (52d24531d) explicitly framed itself as a perf optimization, not an API improvement — its rationale evaporates once the per-consumer bundles each do their own apply-defaults.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Principle: `WorkflowSettings` holds workflow-owned namespaces only.** `{ project, workflow, run }` — the namespaces whose authority belongs to the workflow consumer. `server` and `features` are excluded as an *ownership choice* (live `ServerSettings` is the single authority for deployment-level config), even though `materialize_settings_layer` makes them available in the per-request layer. `ServerSettings` and `UserSettings` are constructed from a complete owned layer at boot and hold every namespace their consumer reads.
|
||||
- **`create_run` gains `storage_root: PathBuf` parameter.** Storage root is deployment-level config — it belongs to whoever booted the server. Lifting it through a per-request bundle (today's `ResolvedSettingsTree.server_storage_root`) was an asymmetric workaround. Pushing it to a parameter eliminates the asymmetry and makes the data-flow honest. The caller resolves env-var interpolation before passing and maps any resolution error to the appropriate transport-level response.
|
||||
- **Persisted settings layer is the request-derived/materialized artifact; do NOT overwrite it.** What lands in `Event::RunCreated.settings` is not the raw submitted layer — `create()` runs `materialize_run(settings, ...)` first (`create.rs:407`) which normalizes and applies graph-time materialization. Treat the persisted field as "the materialized request artifact." `Event::RunCreated` carries two separate fields (`create.rs:234-265`): `settings: <JSON of materialized SettingsLayer>` and `run_dir: String` (where the run actually went). They serve different purposes and should remain separately recorded. The `storage_root` parameter is the runtime authority for `create_run`; `record.run_dir` is the persisted authority for "where is this run?" downstream. `record.settings.server.storage.root` is whatever the materialized layer carries — possibly an env-template that resolved differently at runtime than what `record.run_dir` records. Future code that needs the path should read `run_dir`, not re-resolve the layer. Audit (one grep) confirmed no current `fabro-workflow` consumer reads `settings.server.storage.root` from a persisted record beyond the line being removed.
|
||||
- **Keep standalone `resolve_*_from_file` helpers; rewrite without `Resolver`.** Each becomes `apply_builtin_defaults(file.clone())` + `resolve_<ns>(&layer.<ns>.unwrap_or_default(), &mut errors)`. Rationale: dozens of single-namespace callers (e.g. "is this a dry run?" → `resolve_run_from_file(...)?.execution.mode`); forcing them through a bundle would pay full validation cost for one field.
|
||||
- **`WorkflowSettings::from_layer` returns `Result<Self, Vec<ResolveError>>`.** Asymmetric with `ServerSettings::from_layer` and `UserSettings::from_layer` (which return `fabro_config::Result<Self>` wrapping `Error::Resolve`), but symmetric with `resolve_*_from_file`. Reason: preserves `create_run`'s existing user-visible error format (`; `-joined via `render_resolve_errors`); changing it would be an unowned scope expansion (R6). Existing `Server`/`UserSettings` callers undisturbed.
|
||||
- **`combined_labels` becomes a method on `WorkflowSettings`.** Free fn was only callable in one place.
|
||||
- **Each `*Settings::from_layer` calls `apply_builtin_defaults(layer.clone())` independently.** Acceptable — `create_run` is human-paced, not per-request.
|
||||
- **Visibility of per-namespace `resolve_*` fns stays `pub`.** Called by `*_from_file` standalones and tests.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Should `WorkflowSettings` hold `ServerNamespace` and/or `FeaturesNamespace`?** Neither. Per R4, `WorkflowSettings` holds workflow-owned namespaces only. Server-ops and features ARE available in the per-request layer (via `materialize_settings_layer`), but excluded from the bundle as an ownership choice — live `ServerSettings` is the single authority for deployment-level config.
|
||||
- **Where does `create_run` get `storage.root` if not from the bundle?** From a `storage_root: PathBuf` parameter. Caller (fabro-server, tests) supplies it. See R7.
|
||||
- **Should we migrate every `resolve_*_from_file` caller to a bundle?** No. Standalones stay; bundles are for multi-namespace consumers only.
|
||||
- **Does `combined_labels` belong as a method or free fn?** Method on `WorkflowSettings`.
|
||||
- **What error type does `WorkflowSettings::from_layer` return?** `Result<Self, Vec<ResolveError>>` (matches `resolve_*_from_file`, preserves `create_run`'s existing error message format via `render_resolve_errors`). `Server`/`UserSettings` keep their existing `fabro_config::Result<Self>` shape.
|
||||
- **Should `resolve_storage_root` standalone keep its separate identity?** Yes, keep it (rewrite inline). It's used by `fabro-cli/src/local_server.rs:15` to compute a runtime path without resolving full server settings; that use case remains. Don't migrate that caller in this PR.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Test layout for `WorkflowSettings`.** Either add to `lib/crates/fabro-config/tests/resolve_root.rs` (existing multi-namespace tests live there) or new file `tests/workflow_settings.rs`. Pick during implementation based on what reads better.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [x] **Unit 1: Add `WorkflowSettings` to `fabro-config::context`**
|
||||
|
||||
**Goal:** New consumer bundle for workflow ops, sibling to `ServerSettings`/`UserSettings`. Workflow-only namespaces.
|
||||
|
||||
**Requirements:** R1, R2, R6
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/context.rs`
|
||||
- Modify: `lib/crates/fabro-config/src/lib.rs` (export `WorkflowSettings`)
|
||||
- Test: `lib/crates/fabro-config/tests/resolve_root.rs` OR new `tests/workflow_settings.rs`
|
||||
|
||||
**Approach:**
|
||||
- Add a `pub struct WorkflowSettings` with all fields `pub`:
|
||||
- `pub project: ProjectNamespace`
|
||||
- `pub workflow: WorkflowNamespace`
|
||||
- `pub run: RunNamespace`
|
||||
Every field is `pub` so cross-crate field access from `fabro-workflow` works.
|
||||
- Add `WorkflowSettings::from_layer(layer: &SettingsLayer) -> Result<Self, Vec<ResolveError>>`. Returns `Vec<ResolveError>` (NOT wrapped in `fabro_config::Error::Resolve`) so callers can format via existing `render_resolve_errors`. Body: `apply_builtin_defaults(layer.clone())`, then call `resolve_project`, `resolve_workflow`, `resolve_run` into a shared `Vec<ResolveError>`. Return `Ok(Self { ... })` if `errors.is_empty()`, else `Err(errors)`.
|
||||
- Add `WorkflowSettings::combined_labels(&self) -> HashMap<String, String>` — extend `project.metadata`, then `workflow.metadata`, then `run.metadata` (later wins on key conflict). Match today's `combined_labels` free fn ordering exactly.
|
||||
- Do NOT yet delete `*_into` methods or `Resolver`. This unit is purely additive.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `resolve_*_from_file` helpers in `lib/crates/fabro-config/src/resolve/mod.rs` for the `Result<_, Vec<ResolveError>>` return shape and the per-namespace inline pattern.
|
||||
- For the `combined_labels` ordering, today's free fn in `lib/crates/fabro-workflow/src/operations/create.rs:299-304`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `SettingsLayer::default()` resolves successfully. (Unlike `resolve_server` — which requires explicit `server.auth.methods` per `tests/resolve_root.rs:8` — the workflow-only namespaces have all required defaults in `defaults.toml`.)
|
||||
- Happy path: layer with `project.metadata`, `workflow.metadata`, `run.metadata` produces `combined_labels` containing the union, with later sections winning on key conflict.
|
||||
- Error path: layer with invalid `run.sandbox.provider` (e.g. `"not-a-provider"`) returns `Err(errors)` containing a `ResolveError` for `run.sandbox.provider`. (See existing fixture in `tests/resolve_root.rs:38`.)
|
||||
- Error path: layer with multiple invalid fields *within* `run` (e.g. invalid `sandbox.provider` PLUS another resolve_run-emitting error — pick from existing run-resolve test fixtures during implementation) returns ALL of them in one `Err`. Proves R2 — the shared `errors` vec is being threaded through all `resolve_*` calls in `from_layer`. (Note: `resolve_project` and `resolve_workflow` cannot emit errors today — `lib/crates/fabro-config/src/resolve/{project,workflow}.rs` both ignore the `errors` vec — so a "two namespaces both error" test isn't constructible. The within-`run` test is the closest structural proof we can write.)
|
||||
|
||||
**Verification:**
|
||||
- New tests pass.
|
||||
- `WorkflowSettings` is exported from `fabro_config` and constructable from any test fixture.
|
||||
- All three fields publicly accessible from outside `fabro_config` (smoke-test by reading `wf.run.execution.mode` from a test file).
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 2: Delete `Resolver`; rewrite `*Settings::from_layer` and `resolve_*_from_file` to not depend on it**
|
||||
|
||||
**Goal:** Remove the `Resolver` indirection. Each consumer-bundle constructor and each standalone helper inlines `apply_builtin_defaults` + the per-namespace resolve.
|
||||
|
||||
**Requirements:** R2, R3, R5
|
||||
|
||||
**Dependencies:** Unit 1 (so `WorkflowSettings` exists; not strictly required for this unit, but Unit 3 depends on both and ordering is cleaner).
|
||||
|
||||
**Files:**
|
||||
- Delete: `lib/crates/fabro-config/src/resolve/resolver.rs`
|
||||
- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` (remove `mod resolver;` and `pub use resolver::Resolver;`; rewrite the 6 `resolve_*_from_file` helpers + `resolve_storage_root` to inline)
|
||||
- Modify: `lib/crates/fabro-config/src/context.rs` (rewrite `ServerSettings::from_layer` and `UserSettings::from_layer` without `Resolver`)
|
||||
- Modify: `lib/crates/fabro-config/src/lib.rs` (remove `Resolver` from re-exports)
|
||||
|
||||
**Approach:**
|
||||
- Each `resolve_*_from_file(file)` becomes:
|
||||
```
|
||||
let layer = apply_builtin_defaults(file.clone());
|
||||
let mut errors = Vec::new();
|
||||
let value = resolve_<ns>(&layer.<ns>.clone().unwrap_or_default(), &mut errors);
|
||||
if errors.is_empty() { Ok(value) } else { Err(errors) }
|
||||
```
|
||||
- `resolve_storage_root(file)` becomes a thin extraction from a defaulted layer (same logic as today's `Resolver::storage_root()`).
|
||||
- `ServerSettings::from_layer` and `UserSettings::from_layer` build a defaulted layer once, accumulate errors from both their namespaces into one `Vec<ResolveError>`. Preserves today's "errors from BOTH namespaces" behavior — verify with existing tests in `tests/resolve_server.rs` and `tests/resolve_cli.rs`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Today's `Resolver::server_into(&mut errors)` body shows the inline shape per namespace.
|
||||
- Today's `ServerSettings::from_layer` shows the dual-namespace error accumulation pattern.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: existing `tests/resolve_server.rs`, `tests/resolve_cli.rs`, `tests/resolve_run.rs`, `tests/resolve_workflow.rs`, `tests/resolve_project.rs`, `tests/resolve_features.rs`, `tests/defaults.rs`, `tests/resolve_root.rs` all pass without modification (proves no behavior change for any consumer).
|
||||
- Edge case: `ServerSettings::from_layer` with multiple errors *within* `server` (e.g. invalid `listen.address` AND invalid `ip_allowlist` CIDR — see existing fixture pattern in `tests/resolve_root.rs:22`) returns ALL errors in one `Err`. Proves the shared `errors` vec is threaded through. (`resolve_features` cannot emit errors per `lib/crates/fabro-config/src/resolve/features.rs:5`, so the "errors from both namespaces" pairing isn't constructible — within-namespace multi-error is the closest structural proof.)
|
||||
|
||||
**Verification:**
|
||||
- `cargo build -p fabro-config` clean.
|
||||
- `cargo nextest run -p fabro-config` green with no test changes.
|
||||
- `cargo nextest run -p fabro-config-tests` (if separate) or whatever test target covers `tests/` — green.
|
||||
- `grep -rn "Resolver\b" lib/crates/fabro-config/src` returns no hits except in deleted file.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 3: Migrate `create_run` to `WorkflowSettings`; add `storage_root` parameter; delete `ResolvedSettingsTree` + `resolve_settings_tree` + `combined_labels` free fn**
|
||||
|
||||
**Goal:** `create_run` consumes `WorkflowSettings` for workflow-owned config and `storage_root` from the caller for deployment-owned config. `WorkflowSettings::from_layer` failures keep today's `; `-joined `render_resolve_errors` format (R6). Storage-root interpolation failures move out to the HTTP handler with new error attribution (R5).
|
||||
|
||||
**Requirements:** R1, R5, R6, R7
|
||||
|
||||
**Dependencies:** Unit 1 (needs `WorkflowSettings`), Unit 2 (needs `Resolver` gone — though Unit 3 could technically land before Unit 2).
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-workflow/src/operations/create.rs`
|
||||
- Add `storage_root: PathBuf` parameter to `pub async fn create(...)`.
|
||||
- Delete `struct ResolvedSettingsTree` (lines 63-68).
|
||||
- Delete `fn resolve_settings_tree` (lines 287-297).
|
||||
- Delete `fn combined_labels` (lines 299-304).
|
||||
- Replace `let resolved_settings = resolve_settings_tree(&settings)?;` (line 116) with `WorkflowSettings::from_layer(&settings).map_err(|errors| Error::Precondition(render_resolve_errors(&errors)))?`.
|
||||
- Replace lines 118-127 (today's storage_root resolution + Storage::new call): use the `storage_root` parameter directly. No more `InterpString::resolve(env)` here — caller did it.
|
||||
- Pass `settings` (unmodified) into `PersistCreateOptions`. **Do NOT overwrite `settings.server.storage.root`** — see Key Technical Decisions: persisted layer is the request-derived/materialized artifact (post-`materialize_run`); `record.run_dir` is the path-of-truth.
|
||||
- Replace `combined_labels(&resolved_settings)` (line 166) with `resolved_settings.combined_labels()`.
|
||||
- Imports: remove `Resolver` if directly imported; remove `HashMap` import if no longer used; add `PathBuf` if not already imported.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs:4019` — production caller. Resolve `storage_root` from the live `ServerSettings` and map any interpolation failure to a transport error. **Do NOT use `state.server_storage_dir()`** — that helper panics on resolution failure (`server.rs:659`), which would replace today's `Error::Precondition` graceful-failure path with a process abort. The handler must do its own `resolve_interp_string` (or equivalent) and map errors to an `ApiError` shape used elsewhere in the file.
|
||||
- Modify: `lib/crates/fabro-workflow/src/operations/start.rs:1008,1190` — test helpers (`persisted_workflow` and the bundled-workflow test). Pass a temp dir or fixture path.
|
||||
- Audit and modify: any other callers of `fabro_workflow::operations::create::create()` in workspace tests. Run `grep -rn "operations::create(\|workflow::operations::create" lib/crates/fabro-workflow/tests/ lib/crates/fabro-server/tests/` during implementation.
|
||||
|
||||
**Approach:**
|
||||
- Production caller pattern (sketched — verify error constructor and locking shape during implementation):
|
||||
```
|
||||
// Read the live ServerSettings (Arc<ServerSettings>) and resolve env-var template
|
||||
let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) {
|
||||
Ok(s) => PathBuf::from(s),
|
||||
Err(e) => return ApiError::<chosen-shape>(format!("storage root: {e}")).into_response(),
|
||||
};
|
||||
let created = match Box::pin(operations::create(state.store.as_ref(), create_input, storage_root)).await { ... };
|
||||
```
|
||||
This preserves today's "interpolation failure becomes a graceful HTTP error" semantics. The exact `ApiError` constructor (e.g. `bad_request`, `internal`, or a new variant) is implementer's choice — pick whatever matches today's error shape for similar request-time validation failures in this file. `resolve_interp_string` already exists in the same module (used by `server_storage_dir`).
|
||||
- Test helpers can use `tempfile::tempdir()` or a hardcoded path; pass `tmp.path().to_path_buf()` (or similar) as the third argument.
|
||||
- `validate_sandbox_provider` at line 306 still uses `fabro_config::resolve_run_from_file(&resolved.settings)` — leave it. Different code path, same as today.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Today's `to_error` closure in `create.rs:289-290` for the `render_resolve_errors` mapping shape.
|
||||
- `Error::Precondition` is the established envelope for create-time settings failures.
|
||||
|
||||
**Behavior change in scope (limited to R7 signature change):**
|
||||
- `pub async fn create()` gains a `storage_root: PathBuf` parameter. Three production/test caller sites + workspace test audit.
|
||||
- **No new resolve-time validation surfaces.** The bundle now resolves only `project`/`workflow`/`run`, exactly as today's `ResolvedSettingsTree` did. Server-ops fields (`auth`, `listen`, `ip_allowlist`, `integrations`) are NOT touched by this PR; they remain owned by the live `ServerSettings`.
|
||||
- The InterpString env-var resolution that today happens inside `create()` (lines 119-126) moves to the caller. fabro-server today already has plenty of `InterpString::resolve` patterns to mirror.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: existing `create_run` tests (in `create.rs`'s `mod tests`, in `lib/crates/fabro-workflow/tests/`, in `lib/crates/fabro-server/tests/`) pass after caller updates.
|
||||
- Happy path: `combined_labels()` method produces the same map as today's free fn for the same fixture (proves ordering preservation).
|
||||
- Error path: `SettingsLayer` with malformed `run.sandbox.provider` causes `create_run` to fail with the expected `; `-joined message (proves R6 format preservation).
|
||||
- Edge case: caller passes a non-existent `storage_root` — `create_run` either creates the dir (matches today's behavior via `Storage::new`) or fails with the same error today's `Storage::new` would produce. Verify behavior is identical to today.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build -p fabro-workflow` clean.
|
||||
- `cargo build -p fabro-server` clean.
|
||||
- `cargo nextest run -p fabro-workflow` green.
|
||||
- `cargo nextest run -p fabro-server` green (HTTP handler still works end-to-end).
|
||||
- `grep -rn "ResolvedSettingsTree\|resolve_settings_tree" lib/` returns no hits.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 4: Workspace build + lint sweep**
|
||||
|
||||
**Goal:** Confirm no caller across the workspace still references deleted symbols.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** Units 1-3.
|
||||
|
||||
**Files:** None modified directly; this unit is verification only.
|
||||
|
||||
**Approach:**
|
||||
- `cargo build --workspace` from repo root.
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` (per CLAUDE.md).
|
||||
- `cargo nextest run --workspace`.
|
||||
- `grep -rn "fabro_config::Resolver\|ResolvedSettingsTree\|resolve_settings_tree" lib/ apps/` returns no hits.
|
||||
- `cargo +nightly-2026-04-14 fmt --check --all`.
|
||||
|
||||
**Test expectation:** none — pure verification unit. No new test scenarios; existing tests must pass unchanged.
|
||||
|
||||
**Verification:**
|
||||
- All commands green.
|
||||
- No grep hits for deleted symbols.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** `create_run`'s signature gains a `storage_root: PathBuf` parameter. ~3 production/test callers updated to pass it. No new resolve-time validation runs anywhere — the bundle resolves the same namespaces today's `ResolvedSettingsTree` did.
|
||||
- **Error propagation:** `Server`/`UserSettings` error envelopes unchanged. `WorkflowSettings::from_layer` returns `Result<Self, Vec<ResolveError>>` (a new shape, asymmetric with the other two bundles); `create_run` formats it via `render_resolve_errors` to keep its user-visible error message format identical to today (`; `-joined). See R6. The InterpString-resolution error path that today lives inside `create_run` moves up to the caller — same error class, different attribution.
|
||||
- **State lifecycle risks:** None — pure refactor, no persistent state touched.
|
||||
- **API surface parity:** `fabro_config` public API loses `Resolver` (and the `*_into` methods accessible through it). Gains `pub struct WorkflowSettings`. All other re-exports unchanged. `render_resolve_errors` stays public (used by `create_run` and others). `fabro_workflow::operations::create::create()` signature changes (one new parameter).
|
||||
- **Integration coverage:** Existing tests in `lib/crates/fabro-config/tests/` exercise both single-namespace (`resolve_*_from_file`) and multi-namespace (`*Settings::from_layer`) paths; they cover the integration shape.
|
||||
- **Server-ops authority preserved:** No part of this PR re-resolves server-ops fields (`auth`, `listen`, `ip_allowlist`, `integrations`, `slatedb`, `logging`) per-request. The live `ServerSettings` (owned by `fabro-server` at boot) remains the single authority. `WorkflowSettings` doesn't pretend to hold them.
|
||||
- **Unchanged invariants:** `*Layer` and `*Namespace` types in `fabro-types` — untouched. `apply_builtin_defaults` + `defaults.toml` — untouched. Per-namespace `resolve_*` function bodies — untouched. Consumer field-access patterns (`ctx.user_settings().cli.output.verbosity`) — untouched. `Server`/`UserSettings::from_layer` signatures — untouched.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| `create_run` signature change misses a workspace test caller; build break | Audit step listed in Unit 3 Files (`grep -rn "operations::create("`). Compiler will catch all production paths. |
|
||||
| fabro-server's storage_root resolution call site fails with a new error class (env var missing, parse error) where today the failure happened inside `create_run` | Same error semantics; just attributed to the caller. Mention in PR description so reviewers don't read it as a regression. |
|
||||
| One extra `apply_builtin_defaults(layer.clone())` per bundle on the create path vs. shared-via-Resolver | Acceptable — `create_run` is human-paced. Not in any hot path. |
|
||||
| `combined_labels` method ordering subtly differs from free fn (both extend project → workflow → run) | Match today's free-fn ordering exactly. Existing tests will catch label-ordering regressions. |
|
||||
| Asymmetric `from_layer` return type (`Result<Self, Vec<ResolveError>>` for `Workflow`; `fabro_config::Result<Self>` for `Server`/`User`) reads as inconsistency | Documented as a Key Technical Decision; reason is preserving `create_run`'s error-message format. Future PR could converge all three when the format change is intentional. |
|
||||
| `record.settings.server.storage.root` (the request-derived/materialized artifact) and `record.run_dir` (where the run actually lives) can disagree — e.g. user submitted env-template `"{{ env.X }}"` that resolved at runtime to a different path than the parameter | Documented design (see Key Technical Decisions): persisted settings = request-derived/materialized artifact; `run_dir` = path-of-truth. Audit grep verifies no current consumer reads `settings.server.storage.root` from a persisted record beyond the line being removed. Future readers must use `run_dir` for paths. |
|
||||
| HTTP handler's storage-root resolution failure path differs from today's `Error::Precondition` text | Documented behavior change — moves from a workflow-side `Error::Precondition` to a transport-side `ApiError`. Same failure class (graceful HTTP error), different attribution and error-message text. Mention in PR description. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- No user-facing docs to update (internal refactor).
|
||||
- No migration scripts, feature flags, or rollout concerns.
|
||||
- One signature change: `fabro_workflow::operations::create::create()` gains `storage_root: PathBuf`. Three caller sites + workspace test audit. No resolved-settings semantics change.
|
||||
- Net code: ~150 lines deleted, ~50 added (smaller WorkflowSettings = smaller diff).
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Origin: design conversation in this session.
|
||||
- Related code: `lib/crates/fabro-config/src/resolve/resolver.rs` (introduced commit 52d24531d).
|
||||
- Follow-up plan: `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md` (PR 2 — Combine trait + derive).
|
||||
|
|
@ -0,0 +1,835 @@
|
|||
---
|
||||
title: "refactor: CommandContext alignment at CLI command boundaries"
|
||||
type: refactor
|
||||
status: completed
|
||||
date: 2026-04-23
|
||||
deepened: 2026-04-23
|
||||
---
|
||||
|
||||
# CommandContext Alignment At CLI Command Boundaries
|
||||
|
||||
## Overview
|
||||
|
||||
Finish the partially landed `CommandContext` refactor in `fabro-cli` by
|
||||
making `CommandContext` the shared command-boundary abstraction for
|
||||
invocation plumbing in the CLI command families that already depend on
|
||||
it or immediately reconstruct it. The goal is to stop threading
|
||||
`&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json`
|
||||
through command entrypoints when that data is already part of the same
|
||||
invocation context.
|
||||
|
||||
This is a follow-on plan to the earlier server-access refactor. The
|
||||
current codebase already centralizes `cwd`, merged settings, and server
|
||||
access in `CommandContext`, but command entrypoints still receive raw
|
||||
plumbing and then rebuild a context locally. This plan aligns the
|
||||
surface area around the existing struct instead of introducing a second
|
||||
context or service wrapper.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
`CommandContext` exists today in
|
||||
`lib/crates/fabro-cli/src/command_context.rs`, and many commands
|
||||
already depend on it for `cwd`, `machine_settings`, `user_settings`, and
|
||||
`server()` access. The refactor is only half-finished:
|
||||
|
||||
- the `printer` field and `printer()` accessor are both marked
|
||||
`#[allow(dead_code, reason = "...still being wired through")]`
|
||||
- `main.rs` still passes raw plumbing into most command families
|
||||
- many command entrypoints still accept some combination of
|
||||
`&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json`
|
||||
- those same commands often construct `CommandContext` immediately
|
||||
inside the handler
|
||||
|
||||
That leaves the CLI with two overlapping models:
|
||||
|
||||
- `CommandContext` as the intended abstraction for shared invocation
|
||||
state
|
||||
- raw parameter threading as the de facto command API
|
||||
|
||||
The result is more churn on command signatures, more repeated output
|
||||
format and JSON-gating branches, and a dead-code-marked `printer` field
|
||||
that signals the abstraction boundary is unfinished.
|
||||
|
||||
The refactor needs to finish in a way that preserves current CLI
|
||||
behavior and avoids turning `CommandContext` into a new god object.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- **R1.** In-scope CLI command entrypoints use `CommandContext` as the
|
||||
shared invocation-plumbing abstraction instead of separately receiving
|
||||
`&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json`.
|
||||
- **R2.** `CommandContext` remains narrow: it carries invocation-scoped
|
||||
plumbing and shared derived state, not command-specific args, styles,
|
||||
or workflow-specific data.
|
||||
- **R3.** JSON output, verbosity behavior, printer routing, and global
|
||||
`--json` restrictions remain behaviorally unchanged for existing
|
||||
commands, including `auth status` remaining explicit-global-`--json`
|
||||
only rather than switching to persisted `cli.output.format = "json"`.
|
||||
- **R4.** Target-based and storage-dir-based server resolution semantics
|
||||
remain unchanged, including `CommandContext::server()` behavior and
|
||||
`ServerSummaryLookup::from_client(...)` usage.
|
||||
- **R5.** `main.rs` keeps its current pre-tracing bootstrap ordering for
|
||||
logging and upgrade checks; `CommandContext` begins after that phase.
|
||||
- **R6.** The current dead-code allowance on the `printer` field/accessor
|
||||
is removed by making printer access a real part of the abstraction, or
|
||||
by deleting redundant surface area if a narrower accessor is better.
|
||||
- **R7.** Representative unit and integration coverage exists for the
|
||||
context API, JSON/text output behavior, global `--json` gating, and
|
||||
target/connection-based command families touched by the refactor.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **In scope:** command families in `fabro-cli` that already use
|
||||
`CommandContext` directly or immediately construct one from raw
|
||||
plumbing:
|
||||
`run`, `runs`, `artifact`, `store dump`, `preflight`, `validate`,
|
||||
`graph`, `model`, `secret`, `pr`, `repo`, `auth`, `provider`,
|
||||
`config`, `version`, `doctor`, `system`, and the public
|
||||
`sandbox preview` / `sandbox ssh` command boundary that currently
|
||||
forwards into `commands/run/*` leaf modules.
|
||||
- **In scope:** top-level dispatch cleanup in
|
||||
`lib/crates/fabro-cli/src/main.rs` and family dispatch modules where
|
||||
the only reason raw CLI plumbing is still passed down is command
|
||||
signature inertia.
|
||||
- **Out of scope:** `exec`, `server`, `install`, `upgrade`, `workflow`,
|
||||
`parse`, `render_graph`, hidden analytics/panic upload commands,
|
||||
hidden `run worker`, and `sandbox cp`, except for thin compile-only
|
||||
adapters if needed.
|
||||
- **Out of scope:** changing CLI text, response payloads, exit-code
|
||||
semantics, or server connection behavior beyond what is required to
|
||||
move the plumbing boundary.
|
||||
- **Out of scope:** moving `Styles` ownership into `CommandContext` or
|
||||
making low-level pure rendering helpers context-aware when explicit
|
||||
`Printer` or `Styles` parameters remain clearer.
|
||||
- **Out of scope:** redesigning `server_client.rs` connection semantics.
|
||||
This plan consumes the current `CommandContext::server()` model rather
|
||||
than reopening the earlier server-access design.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `lib/crates/fabro-cli/src/command_context.rs` — current
|
||||
`CommandContext`, constructors, `user_settings()` / `machine_settings()`
|
||||
accessors, and dead-code-marked printer storage.
|
||||
- `lib/crates/fabro-cli/src/main.rs` — top-level command dispatch still
|
||||
passing raw plumbing into most families after bootstrap.
|
||||
- `lib/crates/fabro-cli/src/server_client.rs` — current target and
|
||||
storage-dir connection behavior that must stay unchanged.
|
||||
- `lib/crates/fabro-cli/src/commands/run/create.rs` — existing example
|
||||
of a helper that already takes `&CommandContext`.
|
||||
- `lib/crates/fabro-cli/src/commands/run/mod.rs` — current mixed model:
|
||||
some subcommands build a `CommandContext`, some still read raw
|
||||
`cli.output.format`, and `process_local_json` is already vestigial at
|
||||
this boundary.
|
||||
- `lib/crates/fabro-cli/src/commands/secret/mod.rs` — good family-level
|
||||
pattern where a single derived server/client can be shared across
|
||||
subcommands.
|
||||
- `lib/crates/fabro-cli/src/commands/pr/mod.rs` — representative mixed
|
||||
local/server family using both `CommandContext::base(...)` and
|
||||
`CommandContext::for_target(...)`.
|
||||
- `lib/crates/fabro-cli/src/commands/auth/mod.rs` and
|
||||
`lib/crates/fabro-cli/src/commands/provider/mod.rs` — the clearest
|
||||
examples of raw `process_local_json` still being threaded despite the
|
||||
rest of the state already belonging to the invocation.
|
||||
- `files-internal/testing-strategy.md` — CLI integration tests should
|
||||
stay command-driven and black-box, with implementation-facing behavior
|
||||
covered by unit tests near the code.
|
||||
- `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` — the real public
|
||||
boundary for preview/ssh command dispatch; leaf implementation lives in
|
||||
`commands/run/preview.rs` and `commands/run/ssh.rs`, but the command
|
||||
family boundary is `sandbox`.
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` and
|
||||
`lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` — existing
|
||||
command-owned coverage for those public entrypoints.
|
||||
|
||||
### Current-State Inventory
|
||||
|
||||
- `CommandContext::{base, for_target, for_connection}` currently has
|
||||
**42** call sites across **37** command files under
|
||||
`lib/crates/fabro-cli/src/commands`.
|
||||
- The command tree contains **one** existing helper that already accepts
|
||||
`&CommandContext` directly:
|
||||
`lib/crates/fabro-cli/src/commands/run/create.rs`.
|
||||
- The `printer()` accessor currently has no call sites, which is why the
|
||||
dead-code allowance still exists.
|
||||
- For in-scope command files, the dominant remaining use of raw
|
||||
`&CliNamespace` is reading `cli.output.format` or
|
||||
`cli.output.verbosity`; that is a signal that the data belongs on the
|
||||
shared invocation context rather than each command signature.
|
||||
- `process_local_json` is now concentrated in `auth`, `provider`,
|
||||
`graph`, `sandbox preview`, and `sandbox ssh`. That makes it a good
|
||||
candidate for a dedicated invocation-context field/helper instead of
|
||||
continued parameter threading.
|
||||
|
||||
### Related Context
|
||||
|
||||
- `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md`
|
||||
— earlier plan that introduced the current `CommandContext` and server
|
||||
access model. This plan finishes the command-boundary alignment that
|
||||
document did not fully land.
|
||||
- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md`
|
||||
— recent owner-first context plan that reinforces the repo preference
|
||||
for dense, owner-scoped context objects instead of repeated free-form
|
||||
plumbing.
|
||||
- `git log -- lib/crates/fabro-cli/src/command_context.rs` shows recent
|
||||
follow-on commits including `simplify: drop duplicate settings plumbing
|
||||
from cli/server refactor`, which is consistent with the current goal of
|
||||
collapsing overlapping command-boundary APIs.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No relevant `docs/solutions/` entries currently cover this seam.
|
||||
|
||||
### External References
|
||||
|
||||
- No external research used. The repo already has sufficient local
|
||||
context, existing partial implementation, and tests for this refactor.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Use `CommandContext` as the command-boundary API for in-scope
|
||||
commands.**
|
||||
The abstraction already owns the hard parts: working directory,
|
||||
merged settings, and server access. The remaining refactor should move
|
||||
entrypoint APIs onto that abstraction instead of continuing to thread
|
||||
raw plumbing beside it.
|
||||
|
||||
- **Keep `CommandContext` narrow and invocation-scoped, not god-shaped.**
|
||||
It should own:
|
||||
`printer`, merged CLI-derived settings (`machine_settings`,
|
||||
`user_settings`), `cwd`, config-path context, server-derivation state,
|
||||
and the invocation-only global `--json` flag.
|
||||
It should not own command args, `Styles`, render-only helpers, or
|
||||
workflow/build-specific state.
|
||||
|
||||
- **Do not store or expose the full `CliNamespace` publicly.**
|
||||
Commands in scope only need a small subset of CLI plumbing:
|
||||
output format, output verbosity, and global `--json` restrictions.
|
||||
Output format and verbosity should come from
|
||||
`ctx.user_settings().cli.output`, which already reflects CLI override
|
||||
precedence through merged settings. The only truly extra invocation
|
||||
field is the global `--json` switch, which is not part of persisted
|
||||
settings and therefore belongs on the context explicitly.
|
||||
|
||||
- **Preserve the current error-timing split.**
|
||||
`CommandContext` construction should keep doing what it does today:
|
||||
capture cwd, load local settings, and build merged invocation state.
|
||||
Server-target resolution and server-connection failures should remain
|
||||
deferred to `ctx.server().await?` or existing explicit
|
||||
`resolve_server_target(...)` calls. The refactor should not make
|
||||
malformed target/server resolution errors eager at context-construction
|
||||
time.
|
||||
|
||||
- **Keep `auth status` as an explicit-global-JSON command.**
|
||||
Most in-scope commands should read output mode from
|
||||
`ctx.user_settings().cli.output`, but `auth status` is a special case:
|
||||
it currently emits JSON only for the explicit invocation-wide global
|
||||
`--json` path, not merely because resolved CLI settings say
|
||||
`output.format = json`. That distinction should remain intact, so the
|
||||
context needs both resolved output settings and a separate helper for
|
||||
the explicit global JSON flag.
|
||||
|
||||
- **Create a base invocation context once, then derive target/connection
|
||||
variants from it.**
|
||||
`main.rs` should keep using raw settings during pre-tracing bootstrap.
|
||||
After that, it should build a base `CommandContext` once for each
|
||||
in-scope dispatch path. Command families then derive
|
||||
target-based or connection-based contexts from that base without
|
||||
re-supplying `Printer` and `CliLayer`.
|
||||
|
||||
- **Allow private derivation state inside `CommandContext` if that is the
|
||||
simplest way to avoid raw parameter threading.**
|
||||
Storing a private `CliLayer` or equivalent internal builder state is
|
||||
acceptable if it enables methods like `with_target(...)` or
|
||||
`with_connection(...)` and keeps the raw plumbing hidden behind the
|
||||
abstraction boundary.
|
||||
|
||||
- **Keep render helpers explicit.**
|
||||
Command entrypoints and family dispatchers should align on
|
||||
`CommandContext`, but low-level helpers such as table rendering,
|
||||
summary formatting, and browser-opening routines may keep explicit
|
||||
`Printer` / `Styles` / boolean parameters where that stays simpler than
|
||||
threading the full context downward.
|
||||
|
||||
- **Keep workflow/manifest layer assembly command-local.**
|
||||
Commands such as `run`, `preflight`, `validate`, and `graph` should
|
||||
continue to build their workflow/project/manifests with the existing
|
||||
command-owned helpers. `CommandContext` can provide `cwd`, merged user
|
||||
settings for output behavior, and server access, but it should not
|
||||
absorb manifest-building policy or workflow-layer composition.
|
||||
|
||||
- **Migrate family-by-family with temporary compatibility wrappers if
|
||||
needed.**
|
||||
This is a cross-cutting refactor with wide signature churn. It is
|
||||
better to allow short-lived constructor/adapter overlap during the
|
||||
migration than to force a single giant all-or-nothing patch that is
|
||||
harder to validate.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Should this refactor introduce a second wrapper type such as
|
||||
`Services` or `InvocationContext`?**
|
||||
No. The repo already has a partially landed `CommandContext`, and the
|
||||
simplest aligned design is to finish that abstraction rather than
|
||||
splitting responsibilities across two overlapping context types.
|
||||
|
||||
- **Should `CommandContext` absorb the entire `CliNamespace`?**
|
||||
No. Public command consumers should read output mode and verbosity from
|
||||
`ctx.user_settings().cli.output`, while command-specific configuration
|
||||
stays local to the commands that own it.
|
||||
|
||||
- **Should `process_local_json` become part of `CommandContext`?**
|
||||
Yes. It is invocation-scoped, currently leaks across multiple command
|
||||
signatures, and is the one remaining piece of global command plumbing
|
||||
that is not already represented by merged settings.
|
||||
|
||||
- **Should preview/ssh be treated as `run` work or `sandbox` work in
|
||||
this plan?**
|
||||
Treat them as `sandbox` work at the public command boundary. The leaf
|
||||
implementation modules remain under `commands/run/*`, but the raw
|
||||
plumbing boundary in the current CLI is `commands/sandbox/mod.rs` and
|
||||
the plan should align to that boundary and its tests.
|
||||
|
||||
- **Should `auth status` remain “explicit global JSON only”?**
|
||||
Yes. R3 for this plan is behavioral preservation, and the current
|
||||
contract is that `auth status` emits JSON only when the explicit
|
||||
invocation-global `--json` switch is active.
|
||||
|
||||
- **Should `Styles` move into `CommandContext` in the same pass?**
|
||||
No. That would enlarge the abstraction without addressing the actual
|
||||
duplicated plumbing problem.
|
||||
|
||||
- **Should out-of-scope local commands be forced onto `CommandContext`
|
||||
just for uniformity?**
|
||||
No. This pass should target the families where `CommandContext` already
|
||||
provides real value or is already partially adopted.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Exact API names for derived contexts.**
|
||||
The implementation may settle on `with_target(...)`,
|
||||
`for_target_from(...)`, or similar naming. The important contract is
|
||||
that callers no longer pass raw `Printer` and `CliLayer` repeatedly.
|
||||
|
||||
- **Whether static constructors remain temporarily during migration.**
|
||||
If temporary wrappers reduce compile churn while family-by-family
|
||||
patches land, they are acceptable. Final cleanup should remove the
|
||||
now-redundant raw-plumbing entrypoints from in-scope call sites.
|
||||
|
||||
- **Whether `CommandContext` should be cheaply cloneable or should build
|
||||
derived variants from private state on demand.**
|
||||
Either is acceptable if it preserves the abstraction boundary and does
|
||||
not change runtime behavior.
|
||||
|
||||
## 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.*
|
||||
|
||||
| Boundary | Current shape | Target shape |
|
||||
|---|---|---|
|
||||
| `main.rs` -> command family | `dispatch(args, &cli_settings, &cli_layer, process_local_json, printer)` | `dispatch(args, &base_ctx)` |
|
||||
| family dispatch -> leaf command | raw CLI plumbing plus `CommandContext::for_target(...)` inside the leaf | derive `target_ctx` / `connection_ctx` once from `base_ctx`, then pass `&CommandContext` or already-resolved client/output helpers |
|
||||
| output mode lookup | `cli.output.format` / `cli.output.verbosity` | `ctx.user_settings().cli.output.*` |
|
||||
| global `--json` guard | separate `process_local_json` parameter | `ctx` accessor/helper |
|
||||
| printing | raw `printer` parameter | `ctx.printer()` or a narrower printer extracted from `ctx` |
|
||||
|
||||
Directional flow:
|
||||
|
||||
```text
|
||||
bootstrap raw globals/settings for tracing + upgrade check
|
||||
-> build base CommandContext once for the in-scope command dispatch
|
||||
-> family dispatch receives &base_ctx
|
||||
-> family derives:
|
||||
target_ctx from target args
|
||||
connection_ctx from storage-dir-aware args
|
||||
base/local ctx for settings-only commands
|
||||
-> leaf command reads:
|
||||
ctx.cwd()
|
||||
ctx.machine_settings()
|
||||
ctx.user_settings().cli.output.*
|
||||
ctx.require_no_json_override() or equivalent
|
||||
ctx.printer()
|
||||
ctx.server().await?
|
||||
-> render-only helpers stay explicit over Printer / Styles where simpler
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [x] **Unit 1: Reframe `CommandContext` as the invocation-boundary object**
|
||||
|
||||
**Goal:** Make `CommandContext` capable of carrying the invocation
|
||||
plumbing that is still leaking through command signatures, while keeping
|
||||
the type narrowly scoped.
|
||||
|
||||
**Requirements:** R1, R2, R3, R4, R6
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/command_context.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/main.rs`
|
||||
- Test: `lib/crates/fabro-cli/src/command_context.rs`
|
||||
|
||||
**Approach:**
|
||||
- Add the remaining invocation-only plumbing that does not already exist
|
||||
on the context, specifically the global `--json` switch used today via
|
||||
`process_local_json`.
|
||||
- Make printer access part of the live API so the dead-code allowance on
|
||||
the `printer` field/accessor can be removed.
|
||||
- Add context-derivation methods that let callers obtain target-based or
|
||||
connection-based variants from a base invocation context without
|
||||
re-supplying raw `Printer` and `CliLayer`.
|
||||
- Keep output format and verbosity sourced from
|
||||
`ctx.user_settings().cli.output` rather than storing a second public
|
||||
output-format copy on the side, while still exposing the explicit
|
||||
invocation-global JSON flag separately for commands like `auth status`
|
||||
whose contract is not identical to resolved output format.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/command_context.rs`
|
||||
- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md`
|
||||
for owner-first context boundaries
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: a base context exposes the same output format and verbosity
|
||||
that commands currently read from merged CLI settings.
|
||||
- Happy path: a base context preserves both resolved output settings and
|
||||
the explicit invocation-global JSON flag so commands can distinguish
|
||||
between “resolved output format is JSON” and “user passed global
|
||||
`--json`”.
|
||||
- Happy path: deriving a target-based context preserves printer/global
|
||||
JSON state and resolves server access through the existing
|
||||
`server_client::connect_server_with_settings(...)` path.
|
||||
- Edge case: deriving a connection-based context with a storage-dir
|
||||
override changes only the storage-backed settings path and preserves
|
||||
other invocation-scoped data.
|
||||
- Error path: settings-load failures tied to context construction still
|
||||
surface during context construction, while malformed target resolution
|
||||
remains deferred until `ctx.server().await?` or explicit
|
||||
`resolve_server_target(...)` calls.
|
||||
|
||||
**Verification:**
|
||||
- The `printer` field/accessor is no longer dead code.
|
||||
- There is one clear way to obtain a base context and derive
|
||||
target/connection variants without raw plumbing at the call site.
|
||||
- The context API makes the distinction between resolved output format
|
||||
and explicit global JSON invocation state unambiguous.
|
||||
|
||||
- [x] **Unit 2: Move `main.rs` and the run/preflight/graph/sandbox boundary to context-first dispatch**
|
||||
|
||||
**Goal:** Eliminate raw plumbing from the top-level dispatch path and the
|
||||
run-oriented command family plus the public `sandbox preview` /
|
||||
`sandbox ssh` boundary that already rely heavily on `CommandContext`.
|
||||
|
||||
**Requirements:** R1, R3, R4, R5, R6, R7
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/main.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/command.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/create.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/preview.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/ssh.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/sandbox/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/resume.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/fork.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/wait.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/diff.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/logs.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/cp.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/preflight.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/validate.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/graph.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/run.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/create.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/start.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/attach.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/diff.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/logs.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/preflight.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/validate.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/graph.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/scenario/recovery.rs`
|
||||
|
||||
**Approach:**
|
||||
- Build a base `CommandContext` in `main.rs` only after the existing
|
||||
bootstrap phase completes.
|
||||
- Change the run-family and run-adjacent entrypoints to accept context
|
||||
instead of raw `cli` / `cli_layer` / `printer` bundles.
|
||||
- Replace direct reads of `cli.output.format` and
|
||||
`cli.output.verbosity` with `ctx.user_settings().cli.output.*`.
|
||||
- Replace direct `process_local_json` threading with a context helper
|
||||
for the small number of commands that still need it (`graph`,
|
||||
`commands/sandbox/mod.rs` for the public preview/ssh boundary).
|
||||
- Move the public `sandbox preview` / `sandbox ssh` dispatch boundary to
|
||||
the same context-first shape as the run-family boundary, while leaving
|
||||
the leaf implementation modules in `commands/run/*` if that remains
|
||||
the cleanest internal organization.
|
||||
- Keep `Styles` local to the leaf commands and keep `attach` / `start`
|
||||
client helpers narrow if broadening them adds no value.
|
||||
|
||||
**Execution note:** Start by preserving or expanding characterization
|
||||
coverage for JSON/text output and global `--json` gating before removing
|
||||
the old raw-plumbing signatures from these entrypoints.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/commands/run/create.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/preflight.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/graph.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/sandbox/mod.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `fabro run --detach` still prints a bare run ID in text
|
||||
mode and the same JSON payload in JSON mode after output format moves
|
||||
behind `CommandContext`.
|
||||
- Happy path: `fabro run` / `resume` / `attach` still inherit verbose
|
||||
rendering from CLI output verbosity and preserve idle-sleep behavior.
|
||||
- Edge case: `fabro graph --json` without an explicit output file still
|
||||
rejects the global JSON override exactly as it does today.
|
||||
- Edge case: `fabro sandbox preview --open` still opens the browser only
|
||||
when global JSON mode is not active.
|
||||
- Edge case: `fabro sandbox ssh` still allows `--print` under global
|
||||
JSON mode but continues to reject the unsupported interactive
|
||||
combination.
|
||||
- Error path: `preflight` and `validate` continue to fail on validation
|
||||
errors with the same text-vs-JSON contract and exit behavior.
|
||||
- Integration: create -> start -> attach and resume/recovery flows still
|
||||
resolve targets, stream output, and summarize results through the same
|
||||
black-box CLI contracts.
|
||||
|
||||
**Verification:**
|
||||
- `main.rs` no longer passes the raw plumbing bundle into the run /
|
||||
preflight / validate / graph / sandbox preview-ssh boundary.
|
||||
- Those command boundaries obtain shared invocation data exclusively via
|
||||
`CommandContext`.
|
||||
|
||||
- [x] **Unit 3: Migrate runs/artifact/store families to context-first family dispatch**
|
||||
|
||||
**Goal:** Remove repeated target-context reconstruction from the command
|
||||
families that already build a target-based `CommandContext` immediately
|
||||
and mostly use raw CLI state only for output mode.
|
||||
|
||||
**Requirements:** R1, R3, R4, R6, R7
|
||||
|
||||
**Dependencies:** Units 1-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/archive.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/rm.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/inspect.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/artifact/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/artifact/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/artifact/cp.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/store/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/store/dump.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/archive.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/inspect.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/rm.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/store_dump.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/store.rs`
|
||||
|
||||
**Approach:**
|
||||
- Have each family receive a context-first boundary and derive its
|
||||
target-based command context once, close to the namespace/selector
|
||||
boundary.
|
||||
- Switch output-mode branches to
|
||||
`ctx.user_settings().cli.output.format`.
|
||||
- Preserve existing client-sharing patterns such as
|
||||
`ServerSummaryLookup::from_client(ctx.server().await?)`.
|
||||
- Keep leaf helpers narrow where they already only need a resolved
|
||||
client, resolved run ID, or printer.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/commands/secret/mod.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/artifact/mod.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `runs list`, `archive`, `unarchive`, and `rm` still render
|
||||
the same JSON and text shapes after output-mode decisions move behind
|
||||
the context.
|
||||
- Happy path: `runs inspect` remains a JSON-only projection of server
|
||||
state and still resolves the selected run before fetching the state.
|
||||
- Edge case: “no runs found” and “no artifacts found” text-mode messages
|
||||
remain unchanged.
|
||||
- Error path: ambiguous or missing run selectors still fail through the
|
||||
existing server/client resolution path.
|
||||
- Integration: artifact listing/copy and store dump continue to hit the
|
||||
same server-backed data path and respect output-format selection.
|
||||
|
||||
**Verification:**
|
||||
- These family dispatchers no longer need separate `cli_layer` and
|
||||
`printer` arguments merely to reconstruct a target context.
|
||||
|
||||
- [x] **Unit 4: Migrate PR, secret, and system command families**
|
||||
|
||||
**Goal:** Align the families that mix local settings, target-based
|
||||
server access, and storage-dir-aware server access onto the same
|
||||
context-first boundary.
|
||||
|
||||
**Requirements:** R1, R3, R4, R6, R7
|
||||
|
||||
**Dependencies:** Units 1-3
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/create.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/view.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/merge.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/close.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/secret/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/secret/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/secret/set.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/secret/rm.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/info.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/df.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/events.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/prune.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_list.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_create.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_view.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_close.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/secret.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_list.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_set.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_rm.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/system.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/system_info.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/system_df.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/system_events.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/system_prune.rs`
|
||||
|
||||
**Approach:**
|
||||
- Keep `pr`’s split between base-settings work and target-based run
|
||||
lookup, but move both sides onto a common context-first boundary so
|
||||
raw `cli_layer` / `printer` threading disappears from the family API.
|
||||
- Preserve `secret`’s existing pattern of resolving the server once at
|
||||
the family boundary and passing the client into subcommands.
|
||||
- For `system`, derive connection-aware contexts from the base
|
||||
invocation context so storage-dir override behavior remains explicit
|
||||
and unchanged.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/commands/pr/mod.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/secret/mod.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/system/mod.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: PR list/view/create/merge/close continue to render the same
|
||||
JSON/text contracts and still resolve GitHub credentials from merged
|
||||
local settings.
|
||||
- Happy path: secret list/set/rm continue to resolve one server client at
|
||||
the namespace boundary and honor JSON mode.
|
||||
- Happy path: system info/df/events/prune continue to use
|
||||
storage-dir-aware connection mode where appropriate.
|
||||
- Edge case: missing secret / no matching runs-to-prune / empty PR list
|
||||
still produce the same user-visible text-mode outcomes.
|
||||
- Error path: invalid storage-dir or connection resolution still fails
|
||||
before the command attempts remote work.
|
||||
- Integration: the `system` family continues to respect explicit
|
||||
`--storage-dir` overrides and local daemon resolution semantics.
|
||||
|
||||
**Verification:**
|
||||
- These family boundaries no longer accept raw plumbing bundles when the
|
||||
only reason was to build a `CommandContext` or inspect output mode.
|
||||
|
||||
- [x] **Unit 5: Finish remaining base/target context users**
|
||||
|
||||
**Goal:** Complete the context-first migration for the remaining in-scope
|
||||
command surfaces that already use `CommandContext` but still expose raw
|
||||
plumbing at their entrypoints.
|
||||
|
||||
**Requirements:** R1, R2, R3, R5, R6, R7
|
||||
|
||||
**Dependencies:** Units 1-4
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/model.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/repo/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/repo/init.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/repo/deinit.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/auth/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/auth/login.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/auth/logout.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/auth/status.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/provider/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/provider/login.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/version.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/doctor.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/model.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/model_list.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/model_test.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/repo.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_deinit.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/auth.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/provider.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/provider_login.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/config.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/version.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/doctor.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`
|
||||
|
||||
**Approach:**
|
||||
- Move base-context-only command families (`auth`, `provider`, parts of
|
||||
`repo`) onto `CommandContext` so JSON gating and printer usage come
|
||||
from the shared invocation object.
|
||||
- Use `ctx.machine_settings()` / `ctx.user_settings()` consistently for
|
||||
local settings lookups rather than parallel raw-CLI arguments.
|
||||
- Preserve the special-case JSON contract for `auth status`: explicit
|
||||
invocation-global `--json` remains the only JSON trigger, even if
|
||||
resolved CLI settings say `output.format = json`.
|
||||
- Keep `repo deinit` and similar pure-local leaf helpers narrow if a
|
||||
derived `json_output` boolean or `Printer` extracted from the context
|
||||
keeps the internal helper clearer than passing the full context.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/commands/auth/login.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/auth/status.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/config/mod.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/version.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `auth status` and `provider login` preserve global `--json`
|
||||
restrictions and still resolve the intended server target from merged
|
||||
settings.
|
||||
- Happy path: `config`, `version`, `model`, and `doctor` continue to
|
||||
honor text-vs-JSON output without a direct `CliNamespace` parameter.
|
||||
- Edge case: persisted `cli.output.format = "json"` without explicit
|
||||
global `--json` still leaves `auth status` on its current text-mode
|
||||
path.
|
||||
- Edge case: explicit global `--json` still forces `auth status` onto
|
||||
its JSON output path even though the implementation no longer receives
|
||||
a raw `process_local_json` parameter.
|
||||
- Edge case: `repo init` non-JSON progress output and JSON result
|
||||
payloads stay unchanged.
|
||||
- Edge case: `repo deinit` still stays local and does not accidentally
|
||||
require server access just because the family boundary now uses
|
||||
`CommandContext`.
|
||||
- Error path: missing auth sessions, invalid server targets, and missing
|
||||
GitHub access continue to fail with the same user-facing contract.
|
||||
|
||||
**Verification:**
|
||||
- Remaining in-scope command entrypoints no longer expose the raw
|
||||
plumbing bundle as part of their public internal API.
|
||||
|
||||
- [x] **Unit 6: Remove migration scaffolding and prove the boundary is clean**
|
||||
|
||||
**Goal:** Delete transitional surface area and confirm the in-scope
|
||||
command tree is consistently aligned on `CommandContext`.
|
||||
|
||||
**Requirements:** R1, R2, R6, R7
|
||||
|
||||
**Dependencies:** Units 1-5
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/command_context.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/main.rs`
|
||||
- Modify: in-scope command modules touched by transitional wrappers
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/top_level.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs`
|
||||
|
||||
**Approach:**
|
||||
- Remove temporary wrappers or compatibility constructors that were only
|
||||
present to get through the migration.
|
||||
- Delete stale comments and “still being wired through” dead-code
|
||||
annotations once the boundary is real.
|
||||
- Do a final sweep to ensure in-scope command boundaries are not still
|
||||
taking `&CliNamespace`, `&CliLayer`, `Printer`, and
|
||||
`process_local_json` together out of habit.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Keep cleanup limited to true scaffolding removal; do not reopen command
|
||||
semantics or formatting behavior in the cleanup pass.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none -- cleanup-only unit. Behavioral coverage should
|
||||
already exist from Units 1-5.
|
||||
|
||||
**Verification:**
|
||||
- The command-boundary API is visibly simpler and consistent across the
|
||||
in-scope families.
|
||||
- No in-scope command still looks half-migrated.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** `main.rs` bootstrap -> base `CommandContext` ->
|
||||
family dispatchers -> derived target/connection contexts ->
|
||||
`server_client.rs` / `user_config.rs`. This touches nearly every
|
||||
user-facing CLI family that already talks to settings or server
|
||||
resolution.
|
||||
- **Error propagation:** context-construction failures remain early and
|
||||
synchronous at the command boundary; server access errors still flow
|
||||
through `ctx.server().await?` and should not move deeper into render
|
||||
helpers.
|
||||
- **State lifecycle risks:** the biggest correctness risk is accidental
|
||||
reuse of the wrong derived context, especially storage-dir-aware
|
||||
contexts in the `system` family and target-based contexts in run/PR
|
||||
flows.
|
||||
- **API surface parity:** although this is an internal refactor, it
|
||||
touches external CLI contracts indirectly through JSON/text output,
|
||||
verbosity, global `--json` restrictions, and server-target resolution.
|
||||
- **Integration coverage:** black-box command tests and scenario tests
|
||||
are the main safety net. Unit tests should only cover context
|
||||
construction/derivation and not replace CLI integration coverage.
|
||||
- **Unchanged invariants:** tracing and upgrade bootstrap ordering stays
|
||||
in `main.rs`; `exec` keeps its distinct direct-provider path; server
|
||||
connection semantics stay in `server_client.rs`; `Styles` remain
|
||||
command-local.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Output-mode drift when replacing `cli.output.format` reads with `ctx.user_settings().cli.output.format` | Keep characterization coverage in existing `cmd/*` tests for both text and JSON paths before deleting the raw parameters |
|
||||
| Global `--json` behavior changes while moving `process_local_json` into the context | Add targeted coverage in `json_global.rs`, `auth.rs`, `provider_login.rs`, `graph.rs`, and sandbox preview/ssh command tests |
|
||||
| Storage-dir-aware system commands accidentally derive a target-mode context instead of a connection-mode context | Keep connection-specific derivation explicit and cover `system_info`, `system_df`, `system_events`, and `system_prune` with CLI integration tests |
|
||||
| `CommandContext` grows into a second god object | Keep explicit scope rules: invocation plumbing only, no command args, no `Styles`, no feature-specific render state |
|
||||
| The refactor becomes a giant compile-fix patch with poor reviewability | Land the work family-by-family with temporary compatibility wrappers where needed, and verify each family with its existing tests before cleanup |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- No user-facing documentation changes are expected.
|
||||
- Internal comments in `command_context.rs` and nearby command modules
|
||||
should be updated to describe the final boundary, not the transitional
|
||||
“still being wired through” state.
|
||||
- The earlier April 8 plan should remain as historical context; this plan
|
||||
supersedes it for the command-boundary alignment work.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Prior plan: `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md`
|
||||
- Related plan: `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md`
|
||||
- Related code:
|
||||
`lib/crates/fabro-cli/src/command_context.rs`
|
||||
`lib/crates/fabro-cli/src/main.rs`
|
||||
`lib/crates/fabro-cli/src/server_client.rs`
|
||||
`lib/crates/fabro-cli/src/commands/run/mod.rs`
|
||||
`lib/crates/fabro-cli/src/commands/pr/mod.rs`
|
||||
`lib/crates/fabro-cli/src/commands/secret/mod.rs`
|
||||
`lib/crates/fabro-cli/src/commands/auth/mod.rs`
|
||||
`lib/crates/fabro-cli/src/commands/provider/mod.rs`
|
||||
`lib/crates/fabro-cli/src/commands/system/mod.rs`
|
||||
- Testing guidance: `files-internal/testing-strategy.md`
|
||||
- Related history:
|
||||
`93b6577cd simplify: drop duplicate settings plumbing from cli/server refactor`
|
||||
`367fd9302 refactor(cli): centralize command settings and server access`
|
||||
`4b30a5f16 refactor(cli): route command output through Printer`
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
---
|
||||
title: "refactor: replace merge.rs with Combine trait + derive (uv pattern)"
|
||||
type: refactor
|
||||
status: completed
|
||||
date: 2026-04-23
|
||||
sequence_after: 2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md
|
||||
---
|
||||
|
||||
# refactor: replace merge.rs with Combine trait + derive (uv pattern)
|
||||
|
||||
## For the engineer picking this up
|
||||
|
||||
This is the second of two related settings-architecture refactors. PR 1 (`docs/plans/2026-04-23-001-...`) collapses the resolve-indirection layer (`Resolver`, `ResolvedSettingsTree`, `*_into` methods). PR 2 — this one — replaces the 750-line hand-rolled `merge.rs` with a small `Combine` trait, a dumb derive macro, and a handful of newtypes. Land PR 1 first; the two are orthogonal but PR 1 is smaller and lower-risk.
|
||||
|
||||
The pattern is lifted directly from astral-sh/uv. The user's reference is `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` and `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` — read both before starting. They're short.
|
||||
|
||||
## Overview
|
||||
|
||||
`lib/crates/fabro-config/src/merge.rs` contains 42 hand-rolled `combine_*` functions implementing the v2 settings-merge matrix. Most are mechanical "field: higher.x.or(lower.x)" boilerplate. Replace them with:
|
||||
|
||||
1. A `Combine` trait in `fabro-types` (single `fn combine(self, other: Self) -> Self` method).
|
||||
2. A dumb `#[derive(Combine)]` proc-macro in the existing `fabro-macros` crate that emits `Self { f: self.f.combine(other.f), ... }` field-by-field. ~25 lines.
|
||||
3. Concrete `impl Combine` blocks for `Option<leaf>` types (`Option<String>`, `Option<u64>`, option-wrapped settings enums, etc.). Most are one-liners (`self.or(other)`).
|
||||
4. A generic `impl<T: Combine> Combine for Option<T>` for recursive optional subtables only.
|
||||
5. Newtypes for strategy-bearing collection fields (`ReplaceMap`, `StickyMap`, `MergeMap`, plus exact Vec impls or list newtypes where needed).
|
||||
6. Bespoke `impl Combine` on irregular cases (hooks, splices, whole-replace structs).
|
||||
|
||||
Every merge-participating settings type gets a `Combine` impl. Field-merge structs can derive it. Whole-replace structs and special collection cases must implement it manually. `merge.rs` deletes entirely after its tests are moved.
|
||||
|
||||
## Why this matters (don't skip — it's load-bearing)
|
||||
|
||||
I (the previous engineer / Claude) initially recommended AGAINST a derive macro because attribute-driven derives hide the merge matrix behind macro expansion. **uv's pattern sidesteps this by encoding the rule in the type, not in attributes.** The derive is dead-stupid — it dispatches to `field.combine(other.field)` and that's it. The intelligence lives one level down in `impl Combine` for each field type. The merge matrix is auditable in one file (`combine.rs`) with one stanza per field strategy, and the type system enforces it: you can't accidentally use the wrong merge strategy for a map because `ReplaceMap`, `StickyMap`, and `MergeMap` are different types.
|
||||
|
||||
If you're tempted to add `#[combine(strategy = "...")]` attributes — DON'T. That defeats the entire point. If a field needs a custom rule, it gets a newtype.
|
||||
|
||||
## Reference: how uv does it
|
||||
|
||||
Read these in order:
|
||||
|
||||
1. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` lines 17-52 — the entire derive macro.
|
||||
2. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` — the trait, the `impl_combine_or!` macro for one-line `self.or(other)` impls, and the bespoke struct impls. Read uv's collection impls for context, but do not copy them blindly; Fabro's collection fields need the explicit strategy rules below.
|
||||
3. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/settings.rs` — search for `#[derive(...Combine...)]` to see how it's applied to the `*Options` structs.
|
||||
|
||||
The whole pattern is small — under 400 lines including the derive crate.
|
||||
|
||||
## Fabro-specific wrinkle: collection strategy lives in field types
|
||||
|
||||
uv's pattern works cleanly because every field type is structurally distinct. Fabro currently has multiple bare collection fields whose merge strategy depends on the field, not the Rust type. Do not add a blanket `Combine` impl for `HashMap` or `Vec`; make strategy visible in the field type instead.
|
||||
|
||||
| Field | Type today | Today's `merge.rs` rule | New field type |
|
||||
|-------|------------|-------------------------|----------------|
|
||||
| `project.metadata`, `workflow.metadata`, `run.metadata` | `HashMap<String, String>` | replace whole map if higher/self is non-empty (`merge_string_map_replace`) | `ReplaceMap<String>` |
|
||||
| `run.sandbox.env` | `HashMap<String, InterpString>` | sticky-by-key (`merge_string_map_sticky`) | `StickyMap<InterpString>` |
|
||||
| `daytona.labels` | `HashMap<String, String>` | sticky-by-key (`merge_string_map_sticky`) | `StickyMap<String>` |
|
||||
| `run.agent.mcps`, `cli.exec.agent.mcps` | `HashMap<String, McpEntryLayer>` | sticky-by-key; higher/self replaces the whole entry for the same key | `StickyMap<McpEntryLayer>` |
|
||||
| `server.integrations.github.permissions` | `HashMap<String, InterpString>` | sticky-by-key | `StickyMap<InterpString>` |
|
||||
| `run.notifications` | `HashMap<String, NotificationRouteLayer>` | per-key recursive combine | `MergeMap<NotificationRouteLayer>` |
|
||||
|
||||
To use uv's "rule lives in the type" pattern, introduce these newtypes in `lib/crates/fabro-types/src/settings/`:
|
||||
|
||||
```
|
||||
#[serde(transparent)]
|
||||
pub struct ReplaceMap<V>(pub HashMap<String, V>);
|
||||
|
||||
#[serde(transparent)]
|
||||
pub struct StickyMap<V>(pub HashMap<String, V>);
|
||||
|
||||
#[serde(transparent)]
|
||||
pub struct MergeMap<V>(pub HashMap<String, V>);
|
||||
```
|
||||
|
||||
And impl `Combine` for each. Field declarations in the layer types now document the rule:
|
||||
|
||||
```
|
||||
pub struct ProjectLayer {
|
||||
pub metadata: ReplaceMap<String>, // visibly "replace whole"
|
||||
// ...
|
||||
}
|
||||
pub struct RunSandboxLayer {
|
||||
pub env: StickyMap<InterpString>, // visibly "sticky-by-key"
|
||||
// ...
|
||||
}
|
||||
pub struct RunLayer {
|
||||
pub notifications: MergeMap<NotificationRouteLayer>, // visibly "per-key recursive"
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This is a strict ergonomic improvement: today you have to grep `merge.rs` to find out how `metadata` merges; after, the field type tells you.
|
||||
|
||||
**Migration consideration:** these newtypes need careful serde handling. Use bare newtypes, not `Option<...>`, for fields that are bare `HashMap` today. The existing schema treats "absent" and "present but empty" as the same default empty map, and `merge.rs` falls back when the higher map is empty. Preserve that behavior with `#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]` / equivalent helpers. Verify with the existing `tests/parse_*` tests in `fabro-config` and add new round-trip tests.
|
||||
|
||||
## Irregular cases (hand-written `impl Combine`)
|
||||
|
||||
These don't fit the derive pattern. Each gets a bespoke `impl Combine` on its own type. Look at `merge.rs` for current behavior:
|
||||
|
||||
| Type | Current fn | New shape |
|
||||
|------|-----------|-----------|
|
||||
| `Vec<HookEntry>` | `combine_hooks` | exact `impl Combine for Vec<HookEntry>` or `HookList` newtype — ordered merge with optional `id` replacement |
|
||||
| `Vec<ModelRefOrSplice>` | `splice_model_fallbacks` | exact `impl Combine for Vec<ModelRefOrSplice>` or `ModelFallbackList` newtype — splice with `Splice` sentinel |
|
||||
| `Vec<StringOrSplice>` (events) | `splice_events` | exact `impl Combine for Vec<StringOrSplice>` or `EventList` newtype — splice variant |
|
||||
| `RunPrepareLayer` | `combine_run_prepare` (returns `higher` whole) | `impl Combine for RunPrepareLayer { fn combine(self, _other) = self }` |
|
||||
| `CliTargetLayer` | `combine_cli_target` (returns `higher` whole) | same shape as above |
|
||||
| `ServerListenLayer` | `combine_listen` (returns `higher` whole) | same shape as above |
|
||||
| `FeaturesLayer` | top-level `replace_if_some` | `impl Combine for FeaturesLayer { fn combine(self, _other) = self }` |
|
||||
| `RunArtifactsLayer` | `replace_if_some` | whole-replace `impl Combine` returning `self` |
|
||||
| `RunCheckpointLayer.exclude_globs` | guards "if higher empty, take lower" | hand-written `impl Combine for RunCheckpointLayer` that returns `self` when `self.exclude_globs` is non-empty, else `other` |
|
||||
| `Option<...>` fields currently merged with `higher.x.or(lower.x)` | one-line whole replacement | either concrete `impl Combine for Option<Leaf>` or an inner `impl Combine for Leaf` returning `self` |
|
||||
| `MergeMap<NotificationRouteLayer>` | `combine_notifications` (per-key recursive combine) | `impl<V: Combine> Combine for MergeMap<V>` |
|
||||
| `StickyMap<McpEntryLayer>` | `merge_string_map_sticky` | per-key sticky replacement, not recursive field merge |
|
||||
|
||||
Do not add a blanket `impl<T> Combine for Vec<T>`. Exact Vec impls are allowed only for the concrete special cases above. If an exact impl starts to collide with an option/list strategy, use a newtype instead.
|
||||
|
||||
## Convention to pick: argument order / which side wins
|
||||
|
||||
uv uses `self.combine(other)` where **`self` wins** (self is the higher-precedence layer). Fabro's current `merge.rs` uses `combine_files(lower, higher)` where **higher (the second arg) wins**. Pick one and stick with it.
|
||||
|
||||
Recommendation: adopt uv's "self wins" convention. It reads naturally — `user_settings.combine(defaults)` = "user settings, with defaults as fallback." For multi-layer stacking: `workspace.combine(user).combine(defaults)` gives `workspace > user > defaults`.
|
||||
|
||||
This means `apply_builtin_defaults` becomes:
|
||||
|
||||
```
|
||||
pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer {
|
||||
layer.combine(defaults_layer().clone())
|
||||
}
|
||||
```
|
||||
|
||||
And the existing `combine_files(lower, higher)` callers need their argument order swapped.
|
||||
|
||||
## Core coherence rules
|
||||
|
||||
These rules are mandatory. They avoid the compile-time and behavior traps that a naive port from uv would introduce in Fabro.
|
||||
|
||||
1. `Combine` lives in `fabro-types`, because the settings layer structs live in `fabro-types` and `fabro-config` already depends on `fabro-types`. Putting the trait in `fabro-config` would create a dependency cycle.
|
||||
2. The derive macro lives in the existing `fabro-macros` proc-macro crate. `fabro-types` already depends on `fabro-macros`, so do not create a new `fabro-config-macros` crate.
|
||||
3. Do not implement `Combine` for scalar inner types like `String`, `bool`, `InterpString`, or settings enums. Implement `Combine` for `Option<String>`, `Option<bool>`, `Option<InterpString>`, `Option<RunMode>`, etc. This lets `impl<T: Combine> Combine for Option<T>` coexist with concrete scalar option impls.
|
||||
4. `impl<T: Combine> Combine for Option<T>` is for recursive optional subtables only. It preserves whole-replace behavior only when the inner type's `Combine` impl returns `self`.
|
||||
5. Do not derive `Combine` for a type just because it is a `*Layer`. Derive only when the current `merge.rs` behavior is field-by-field merge. If current behavior is `higher.or(lower)` or `replace_if_some`, the inner type needs a whole-replace `Combine` impl returning `self`.
|
||||
6. Do not implement blanket `Combine` for `Vec<T>` or `HashMap<K, V>`. Use exact impls or strategy newtypes.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [x] **Unit 1: Add `Combine` trait + option leaf impls in `fabro-types`**
|
||||
|
||||
**Goal:** Lay down the trait infrastructure with no derive yet.
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-types/src/settings/combine.rs`
|
||||
- Modify: `lib/crates/fabro-types/src/settings/mod.rs` (add `mod combine; pub use combine::Combine;`)
|
||||
- Modify: `lib/crates/fabro-types/src/lib.rs` if desired to re-export `settings::Combine` at the crate root.
|
||||
|
||||
**Approach:**
|
||||
- Copy uv's trait shape verbatim. Convention: `self` wins.
|
||||
- Add `impl<T: Combine> Combine for Option<T>` for recursive optional subtables:
|
||||
```
|
||||
match (self, other) {
|
||||
(Some(this), Some(fallback)) => Some(this.combine(fallback)),
|
||||
(this, fallback) => this.or(fallback),
|
||||
}
|
||||
```
|
||||
- Add an `impl_combine_or_option!` macro for one-line whole-replace option leaves. List every leaf scalar/enum used in `*Layer` types as `Option<T>` impls: `Option<String>`, `Option<bool>`, numeric options, `Option<InterpString>`, `Option<RunMode>`, `Option<ApprovalMode>`, `Option<ObjectStoreProvider>`, `Option<WebhookStrategy>`, `Option<ServerAuthMethod>` where applicable, etc. Grep `lib/crates/fabro-types/src/settings/` for the full list.
|
||||
- Add concrete whole-replace option impls for composite leaves that should not recursively merge, such as `Option<HashMap<String, toml::Value>>` for `run.inputs` and any `Option<Vec<...>>` fields that remain bare vectors after Unit 3.
|
||||
- Do **not** implement `Combine` for scalar inner types (`String`, `bool`, `InterpString`, enums). If `String: Combine` exists, it conflicts with `impl Combine for Option<String>`.
|
||||
- Do **not** implement blanket `Combine` for `Vec<T>` or `HashMap<K, V>`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `Some("a").combine(Some("b")) == Some("a")`.
|
||||
- Happy path: `None.combine(Some("a")) == Some("a")`.
|
||||
- Edge case: `Some("a").combine(None) == Some("a")`.
|
||||
- Recursive option: `Some(FieldMergeLayer { a: Some(1), b: None }).combine(Some(FieldMergeLayer { a: Some(2), b: Some(3) }))` preserves `a = 1` and inherits `b = 3`.
|
||||
- Whole-replace option: `Some(WholeReplaceLayer { a: Some(1), b: None }).combine(Some(WholeReplaceLayer { a: Some(2), b: Some(3) }))` returns the `self` layer without inheriting `b`.
|
||||
- Each option leaf type gets at least one assertion proving `self.or(other)` semantics.
|
||||
|
||||
**Verification:** `cargo build -p fabro-types` clean. New unit tests pass.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 2: Add `#[derive(Combine)]` to the existing `fabro-macros` crate**
|
||||
|
||||
**Goal:** The dumb derive macro.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-macros/src/lib.rs`
|
||||
- Modify: `lib/crates/fabro-types/src/lib.rs` or `lib/crates/fabro-types/src/settings/mod.rs` to re-export the derive if that keeps call sites simple.
|
||||
|
||||
**Approach:**
|
||||
- Copy uv's `derive_combine` verbatim from `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs:17-52`.
|
||||
- The derive emits `Self { f1: self.f1.combine(other.f1), ... }` for named fields. Unnamed fields / enums: `unimplemented!()` (uv does the same; we don't need them).
|
||||
- The derive may reference `crate::settings::Combine` or `crate::Combine`; if using `crate::Combine`, re-export the trait at the `fabro-types` crate root first. This works because the derive output expands inside `fabro-types`, where the layer structs live.
|
||||
- Do not derive `Combine` in `fabro-config`; `fabro-config` is the consumer of the combined settings tree, not the owner of the layer types.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: a small struct `#[derive(Combine)] struct Foo { a: Option<u32>, b: Option<String> }` produces a working `Combine` impl. Proven by a unit test that asserts `Foo { a: Some(1), b: None }.combine(Foo { a: Some(2), b: Some("x".into()) }) == Foo { a: Some(1), b: Some("x".into()) }`.
|
||||
- Integration: nested struct with an `Option<InnerStruct>` field combines recursively.
|
||||
- Whole-replace integration: nested struct with an `Option<WholeReplaceStruct>` field does not inherit fields when both sides are `Some`.
|
||||
|
||||
**Verification:** `cargo build -p fabro-macros` and `cargo build -p fabro-types` clean. Derive test passes.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 3: Introduce collection strategy newtypes and migrate strategy-bearing maps**
|
||||
|
||||
**Goal:** Move map merge rules into the type system.
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-types/src/settings/maps.rs` (or extend existing module)
|
||||
- Modify: `lib/crates/fabro-types/src/settings/mod.rs` to export the newtypes
|
||||
- Modify: `lib/crates/fabro-types/src/settings/combine.rs` or `maps.rs` to add `impl Combine for ReplaceMap<V>`, `StickyMap<V>`, and `MergeMap<V>`.
|
||||
- Modify field declarations in `lib/crates/fabro-types/src/settings/{project,workflow,run,cli,server}.rs`:
|
||||
- `project.metadata`, `workflow.metadata`, `run.metadata` -> `ReplaceMap<String>`
|
||||
- `run.sandbox.env` -> `StickyMap<InterpString>`
|
||||
- `daytona.labels` -> `StickyMap<String>`
|
||||
- `run.agent.mcps`, `cli.exec.agent.mcps` -> `StickyMap<McpEntryLayer>`
|
||||
- `server.integrations.github.permissions` -> `StickyMap<InterpString>`
|
||||
- `run.notifications` -> `MergeMap<NotificationRouteLayer>`
|
||||
- Audit every consumer of these fields. They currently access `HashMap` directly; with newtypes they'll need `.0`, `Deref`, iterator helpers, or explicit conversion at resolve boundaries.
|
||||
|
||||
**Approach:**
|
||||
- `#[serde(transparent)]` on the newtype.
|
||||
- Implement `Deref<Target = HashMap<String, V>>`, `DerefMut`, `From<HashMap<String, V>>`, and `IntoIterator` as needed so existing read-side consumers keep working without churn.
|
||||
- Implement `Default`, `Debug`, `Clone`, `PartialEq`, `Serialize`, `Deserialize`.
|
||||
- `impl<V> Combine for ReplaceMap<V>`: if `self.0` is non-empty take self, else take other (matches today's `merge_string_map_replace` with self/other swapped per the convention chosen in Unit 1).
|
||||
- `impl<V> Combine for StickyMap<V>`: per-key insert-or-keep where `self` wins on conflict (matches `merge_string_map_sticky` after swapping argument order).
|
||||
- `impl<V: Combine> Combine for MergeMap<V>`: per-key recursive combine where `self` wins on conflict and missing keys are inherited from `other`.
|
||||
- Keep `run.inputs: Option<HashMap<String, toml::Value>>` as a concrete whole-replace option leaf unless there is a stronger reason to newtype it.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `ReplaceMap` round-trips through TOML deserialize/serialize unchanged.
|
||||
- Happy path: `ReplaceMap::combine` with both non-empty takes `self`'s map whole.
|
||||
- Happy path: `StickyMap::combine` merges keys, `self` wins on conflict.
|
||||
- Happy path: `MergeMap<NotificationRouteLayer>` recursively combines an existing route by key.
|
||||
- Edge case: `ReplaceMap` empty + `StickyMap` empty + `MergeMap` empty — combine produces empty values and preserves fallback behavior.
|
||||
- Integration: existing `tests/parse_*` tests still parse the same TOML fixtures without modification (proves transparent serde works).
|
||||
|
||||
**Verification:** All existing parse tests pass. New newtype tests pass.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 4: Add selective `Combine` impls to settings layer types**
|
||||
|
||||
**Goal:** Cover every settings type. After this unit, `SettingsLayer.combine(other)` works.
|
||||
|
||||
**Files:**
|
||||
- Modify: `*Layer` definitions in `lib/crates/fabro-types/src/settings/{cli,project,workflow,run,server,features,layer}.rs` to add `#[derive(Combine)]` only where current behavior is field-by-field merge.
|
||||
- Modify: whole-replace and irregular types to add bespoke `impl Combine`. Place each impl next to its type definition, or group them in `settings/combine.rs` if local style reads cleaner.
|
||||
- Move the tests currently embedded in `lib/crates/fabro-config/src/merge.rs` into a surviving test module before deleting `merge.rs`.
|
||||
|
||||
**Approach:**
|
||||
- Walk through `merge.rs` function-by-function. For each `combine_<name>(lower, higher) -> NameLayer`:
|
||||
- If the body is field-by-field `higher.x.or(lower.x)` for every field → add `#[derive(Combine)]` to the struct, delete the function.
|
||||
- If one or two fields are special (`merge_string_map_replace` etc.) → if those fields have been migrated to newtypes (Unit 3), the derive handles it. If not, hand-write `impl Combine` on the struct.
|
||||
- If the body returns `higher` whole → hand-written `impl Combine` returning `self`.
|
||||
- If a field currently uses `higher.x.or(lower.x)` and `x` is an `Option<SomeLayer>`, treat it as whole-replace unless the current `merge.rs` calls a recursive `combine_*` helper for that type elsewhere.
|
||||
- For Vec types with splice semantics (model fallbacks, events) → exact bespoke `impl Combine for Vec<...>` or a list newtype. The current `splice_*` helpers can be inlined into the impls. Do not add a blanket Vec impl.
|
||||
- For hook lists → exact bespoke `impl Combine for Vec<HookEntry>` or a `HookList` newtype preserving the current id-aware ordering.
|
||||
- For `RunCheckpointLayer` → hand-write the special "self non-empty wins, otherwise fallback" behavior.
|
||||
- For `FeaturesLayer`, `RunPrepareLayer`, `RunArtifactsLayer`, `CliTargetLayer`, and `ServerListenLayer` → whole-replace impl returning `self`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: for each layer struct, an existing `merge.rs` test (search for `#[test]` in `lib/crates/fabro-config/src/merge.rs` or `tests/`) continues to pass with the new infrastructure substituted. (Recommended: write the new infra to coexist with `merge.rs` initially, swap call sites in Unit 5, then delete `merge.rs` in Unit 6.)
|
||||
- Edge case: hooks ordered merge with id replacement — assert the exact ordering produced today is preserved.
|
||||
- Edge case: model fallbacks splice — verify `Splice` sentinel handling.
|
||||
- Edge case: a whole-replace optional subtable with missing fields does not inherit fallback fields.
|
||||
|
||||
**Verification:** `cargo build --workspace` clean. Existing merge tests still pass when run against the new trait dispatch.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 5: Swap `combine_files` call sites to use `Combine` trait; rewrite `apply_builtin_defaults`**
|
||||
|
||||
**Goal:** All merging goes through `.combine()`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/defaults.rs` — `apply_builtin_defaults` becomes `layer.combine(defaults_layer().clone())`.
|
||||
- Modify: every caller of `combine_files` — search `grep -rn "combine_files" lib/`. Audit and convert each.
|
||||
- Modify callers to import `fabro_types::settings::Combine` (or `fabro_types::Combine` if re-exported at crate root).
|
||||
- Argument-order check: today's `combine_files(lower, higher)` becomes `higher.combine(lower)` under the "self wins" convention.
|
||||
|
||||
**Verification:** `cargo build --workspace` clean. `cargo nextest run --workspace` green.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Unit 6: Delete `merge.rs`**
|
||||
|
||||
**Goal:** Final cleanup.
|
||||
|
||||
**Files:**
|
||||
- Delete: `lib/crates/fabro-config/src/merge.rs`
|
||||
- Modify: `lib/crates/fabro-config/src/lib.rs` remove `mod merge;` and any re-exports of merge helpers (`combine_files`, etc.)
|
||||
- Confirm the characterization tests formerly inside `merge.rs` now live in a surviving test module.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build --workspace` clean.
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- `cargo nextest run --workspace` green.
|
||||
- `grep -rn "combine_files\|merge_option\|merge_string_map" lib/` returns no hits.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **API surface:** `fabro_config::combine_files` removed. `fabro_types::settings::Combine` becomes the trait API for layer combination. Likely no external callers depend on `combine_files`, but verify with grep before deleting.
|
||||
- **Consumer code:** Field reads on `metadata`/`env`/`labels`/`notifications`/`mcps`/`permissions` may need `.0`, iterator helpers, or conversion if `Deref` is not sufficient. Audit during Unit 3.
|
||||
- **Serde compatibility:** `#[serde(transparent)]` on newtypes must round-trip identically to today's bare `HashMap` fields. Existing `tests/parse_*` are the canary.
|
||||
- **Test coverage:** every `merge.rs` test must keep passing, just running through the new infrastructure. Move those tests before deleting `merge.rs`; do not lose behavior parity coverage.
|
||||
- **Net code change:** ~750 lines of `merge.rs` removed; ~25 (derive macro) + ~160 (`combine.rs` trait + option leaf impls + strategy impls) + ~80 (newtypes + consumer adjustments) + bespoke impls. Net reduction is still expected, but exact line count is less important than preserving the merge matrix.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| `Deref<Target = HashMap>` on newtypes doesn't cover every access pattern (e.g., methods that take `HashMap` by value or by `&mut`) | Audit consumers during Unit 3. Add `From`/`Into` impls or change consumer code to take the newtype. |
|
||||
| Serde `#[serde(transparent)]` doesn't behave identically to bare HashMap for some TOML edge cases | The existing `tests/parse_*` fixtures are the contract. Run them after Unit 3; any failure is the migration bug. |
|
||||
| Convention swap (lower/higher → self/other) introduces subtle bugs at `combine_files` call sites | Do Unit 5 carefully. Each call-site swap is a 2-line diff but easy to invert. Lean on existing merge tests. |
|
||||
| Generic `Option<T: Combine>` accidentally field-merges a subtable that currently replaces whole | Unit 4 must audit every `higher.x.or(lower.x)` site. Any inner type with whole-replace semantics gets `impl Combine for T { fn combine(self, _other) -> Self { self } }` or a concrete `impl Combine for Option<T>` leaf impl. Add a regression test where the fallback has a field missing from self and confirm it is not inherited. |
|
||||
| Exact `Vec<...>` impls collide with a future broad list strategy | Do not add blanket `Vec<T>` impls. If exact Vec impls become awkward, migrate those fields to explicit newtypes (`HookList`, `ModelFallbackList`, `EventList`) instead. |
|
||||
| Macro path breaks because derive output expands inside `fabro-types` modules | Re-export `Combine` at a stable path before deriving. Prefer `crate::settings::Combine` or `crate::Combine` and verify with `cargo build -p fabro-types` in Unit 2. |
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved (from design conversation)
|
||||
|
||||
- **Trait + dumb derive vs. attribute-driven derive?** Dumb derive (uv pattern). Attributes hide the merge matrix.
|
||||
- **Where does `Combine` live?** `fabro-types`, because the layer types live there and `fabro-config` depends on `fabro-types`.
|
||||
- **Where does the derive live?** Existing `fabro-macros`, not a new `fabro-config-macros` crate.
|
||||
- **How does generic `Option<T: Combine>` avoid changing whole-replace behavior?** Whole-replace inner types implement `Combine` by returning `self`; scalar leaves get concrete `Option<T>` impls and do not implement `Combine` on `T`.
|
||||
- **Newtypes for map variants?** Yes. Rule lives in the type. Include `ReplaceMap`, `StickyMap`, and `MergeMap`, not just the original three `HashMap<String, String>` cases.
|
||||
- **Blanket collection impls?** No blanket `Vec<T>` or `HashMap<K, V>` impls.
|
||||
- **Convention?** "Self wins" (uv). Swap argument order at call sites in Unit 5.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Should `combine_files` survive as a thin wrapper or be deleted entirely?** Probably delete; `layer.combine(other)` reads fine.
|
||||
- **Exact Vec impls or list newtypes for hooks/fallbacks/events?** Start with exact impls if they stay conflict-free. Move to `HookList`, `ModelFallbackList`, or `EventList` if type coherence or readability gets worse.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- **Reference implementation:** `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` and `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` (read both fully before starting).
|
||||
- **Sequencing:** Land `2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md` first.
|
||||
- **Design conversation:** preserved in chat transcript with Bryan dated 2026-04-23. Key decisions: rule-lives-in-type beats attribute-driven derive; collection merge strategies need newtypes; `WorkflowSettings` includes `ServerNamespace` (PR 1 decision); `Combine` is the right shape but `Resolve` is not (resolve is per-field, not per-type).
|
||||
- **Origin of current `merge.rs`:** "v2 merge matrix implementation" header note; rules trace to settings TOML redesign requirements (`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md` and the follow-on handoffs).
|
||||
|
|
@ -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 <RUN>
|
||||
fabro store dump abc123 -o ./debug-output
|
||||
fabro dump <RUN>
|
||||
fabro dump abc123 -o ./debug-output
|
||||
```
|
||||
|
||||
| Argument / Flag | Description |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ The web wizard produces **the same on-disk state** as the CLI install. The TOML-
|
|||
Files written:
|
||||
|
||||
- `~/.fabro/settings.toml` — server config, auth methods, GitHub integration strategy.
|
||||
- `<storage_dir>/server.env` — `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen.
|
||||
- `<storage_dir>/server.env` — `SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen.
|
||||
- `<storage_dir>/vaults/default/secrets.json` — vault entries for LLM API key credentials and (if Token strategy) `GITHUB_TOKEN`. Path matches `Storage::secrets_path()` at `lib/crates/fabro-config/src/storage.rs:38`.
|
||||
- `<storage_dir>/server.dev-token` — the per-storage dev token, written via `Storage::server_state().dev_token_path()` at `storage.rs:103`. The CLI install also writes a home-level mirror at `Home::from_env().dev_token_path()` (`install.rs:1994-1999`); the web flow does the same to keep parity, since the home-level file is what tooling outside the storage dir expects to find.
|
||||
- Artifact store metadata stamped with `FABRO_VERSION` via `write_artifact_store_metadata` (`install.rs:1458`).
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ pub(crate) struct InspectArgs {
|
|||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct StoreDumpArgs {
|
||||
pub(crate) struct DumpArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
|
|
@ -803,10 +803,6 @@ pub(crate) struct RunWorkerArgs {
|
|||
#[arg(long, hide = true)]
|
||||
pub(crate) storage_dir: Option<PathBuf>,
|
||||
|
||||
/// Short-lived bearer token for artifact uploads
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) artifact_upload_token: Option<String>,
|
||||
|
||||
/// Run scratch directory
|
||||
#[arg(long)]
|
||||
pub(crate) run_dir: PathBuf,
|
||||
|
|
@ -1001,8 +997,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
|
||||
|
|
@ -1085,9 +1081,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",
|
||||
|
|
@ -1199,18 +1193,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)]
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ 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::cli::{CliLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_types::settings::{Combine, SettingsLayer};
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::args::{ServerConnectionArgs, ServerTargetArgs};
|
||||
use crate::args::{
|
||||
ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override,
|
||||
};
|
||||
use crate::server_client::Client;
|
||||
use crate::{server_client, user_config};
|
||||
|
||||
|
|
@ -26,88 +27,62 @@ pub(crate) enum ServerMode {
|
|||
}
|
||||
|
||||
pub(crate) struct CommandContext {
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "This item is kept for command plumbing that is still being wired through."
|
||||
)]
|
||||
printer: Printer,
|
||||
cwd: PathBuf,
|
||||
base_config_path: PathBuf,
|
||||
machine_settings: SettingsLayer,
|
||||
user_settings: UserSettings,
|
||||
server_mode: ServerMode,
|
||||
server: OnceCell<Arc<Client>>,
|
||||
printer: Printer,
|
||||
process_local_json: bool,
|
||||
cwd: PathBuf,
|
||||
base_config_path: PathBuf,
|
||||
cli_layer: CliLayer,
|
||||
machine_settings: SettingsLayer,
|
||||
user_settings: UserSettings,
|
||||
server_mode: ServerMode,
|
||||
server: OnceCell<Arc<Client>>,
|
||||
}
|
||||
|
||||
impl CommandContext {
|
||||
pub(crate) fn base(printer: Printer, cli_layer: &CliLayer) -> Result<Self> {
|
||||
Self::new(printer, ServerMode::None, cli_layer)
|
||||
}
|
||||
|
||||
pub(crate) fn for_target(
|
||||
args: &ServerTargetArgs,
|
||||
printer: Printer,
|
||||
cli_layer: &CliLayer,
|
||||
) -> Result<Self> {
|
||||
Self::new(
|
||||
printer,
|
||||
ServerMode::ByTarget {
|
||||
target_override: args.server.clone(),
|
||||
},
|
||||
cli_layer,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn for_connection(
|
||||
args: &ServerConnectionArgs,
|
||||
printer: Printer,
|
||||
cli_layer: &CliLayer,
|
||||
) -> Result<Self> {
|
||||
Self::new(
|
||||
printer,
|
||||
ServerMode::ByStorageDir {
|
||||
target_override: args.target.server.clone(),
|
||||
storage_dir_override: args.storage_dir.clone_path(),
|
||||
},
|
||||
cli_layer,
|
||||
)
|
||||
}
|
||||
|
||||
fn new(printer: Printer, server_mode: ServerMode, cli_layer: &CliLayer) -> Result<Self> {
|
||||
pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result<Self> {
|
||||
let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?;
|
||||
let printer = printer_from_verbosity(user_settings.cli.output.verbosity);
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
let base_config_path = user_config::active_settings_path(None);
|
||||
let disk_settings = match &server_mode {
|
||||
ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?,
|
||||
ServerMode::ByStorageDir {
|
||||
storage_dir_override,
|
||||
..
|
||||
} => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?,
|
||||
};
|
||||
let machine_settings = combine_files(disk_settings, SettingsLayer {
|
||||
cli: Some(cli_layer.clone()),
|
||||
..SettingsLayer::default()
|
||||
});
|
||||
let user_settings = fabro_config::UserSettings::from_layer(&machine_settings)?;
|
||||
|
||||
Ok(Self {
|
||||
printer,
|
||||
process_local_json,
|
||||
cwd,
|
||||
base_config_path,
|
||||
cli_layer: cli_layer.clone(),
|
||||
machine_settings,
|
||||
user_settings,
|
||||
server_mode,
|
||||
server_mode: ServerMode::None,
|
||||
server: OnceCell::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "This item is kept for command plumbing that is still being wired through."
|
||||
)]
|
||||
pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result<Self> {
|
||||
self.with_server_mode(ServerMode::ByTarget {
|
||||
target_override: args.server.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result<Self> {
|
||||
self.with_server_mode(ServerMode::ByStorageDir {
|
||||
target_override: args.target.server.clone(),
|
||||
storage_dir_override: args.storage_dir.clone_path(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn printer(&self) -> Printer {
|
||||
self.printer
|
||||
}
|
||||
|
||||
pub(crate) fn explicit_json_requested(&self) -> bool {
|
||||
self.process_local_json
|
||||
}
|
||||
|
||||
pub(crate) fn require_no_json_override(&self) -> Result<()> {
|
||||
require_no_json_override(self.process_local_json)
|
||||
}
|
||||
|
||||
pub(crate) fn cwd(&self) -> &Path {
|
||||
&self.cwd
|
||||
}
|
||||
|
|
@ -120,6 +95,14 @@ impl CommandContext {
|
|||
&self.user_settings
|
||||
}
|
||||
|
||||
pub(crate) fn json_output(&self) -> bool {
|
||||
self.user_settings.cli.output.format == OutputFormat::Json
|
||||
}
|
||||
|
||||
pub(crate) fn verbose(&self) -> bool {
|
||||
self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose
|
||||
}
|
||||
|
||||
pub(crate) async fn server(&self) -> Result<Arc<Client>> {
|
||||
let server_mode = self.server_mode.clone();
|
||||
let base_config_path = self.base_config_path.clone();
|
||||
|
|
@ -149,4 +132,161 @@ impl CommandContext {
|
|||
|
||||
Ok(Arc::clone(client))
|
||||
}
|
||||
|
||||
fn with_server_mode(&self, server_mode: ServerMode) -> Result<Self> {
|
||||
// Always reload settings for the requested derivation mode so the result
|
||||
// depends only on the requested mode, not on whichever derived context
|
||||
// happened to call into this helper.
|
||||
let (machine_settings, user_settings) =
|
||||
load_merged_settings(&self.cli_layer, &server_mode)?;
|
||||
|
||||
Ok(Self {
|
||||
printer: self.printer,
|
||||
process_local_json: self.process_local_json,
|
||||
cwd: self.cwd.clone(),
|
||||
base_config_path: self.base_config_path.clone(),
|
||||
cli_layer: self.cli_layer.clone(),
|
||||
machine_settings,
|
||||
user_settings,
|
||||
server_mode,
|
||||
server: OnceCell::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_merged_settings(
|
||||
cli_layer: &CliLayer,
|
||||
server_mode: &ServerMode,
|
||||
) -> Result<(SettingsLayer, UserSettings)> {
|
||||
let disk_settings = match server_mode {
|
||||
ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?,
|
||||
ServerMode::ByStorageDir {
|
||||
storage_dir_override,
|
||||
..
|
||||
} => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?,
|
||||
};
|
||||
merge_settings_layer(disk_settings, cli_layer)
|
||||
}
|
||||
|
||||
fn merge_settings_layer(
|
||||
disk_settings: SettingsLayer,
|
||||
cli_layer: &CliLayer,
|
||||
) -> Result<(SettingsLayer, UserSettings)> {
|
||||
let machine_settings = SettingsLayer {
|
||||
cli: Some(cli_layer.clone()),
|
||||
..SettingsLayer::default()
|
||||
}
|
||||
.combine(disk_settings);
|
||||
let user_settings = UserSettings::from_layer(&machine_settings)?;
|
||||
Ok((machine_settings, user_settings))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fabro_config::parse_settings_layer;
|
||||
use fabro_config::user::apply_storage_dir_override;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::{CommandContext, ServerMode, merge_settings_layer};
|
||||
|
||||
fn cli_layer_with_json_and_verbose() -> CliLayer {
|
||||
CliLayer {
|
||||
output: Some(CliOutputLayer {
|
||||
format: Some(OutputFormat::Json),
|
||||
verbosity: Some(OutputVerbosity::Verbose),
|
||||
}),
|
||||
..CliLayer::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext {
|
||||
let cli_layer = cli_layer_with_json_and_verbose();
|
||||
let (machine_settings, user_settings) =
|
||||
merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer)
|
||||
.expect("settings should merge");
|
||||
CommandContext {
|
||||
printer,
|
||||
process_local_json,
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
base_config_path: PathBuf::from("/tmp/settings.toml"),
|
||||
cli_layer,
|
||||
machine_settings,
|
||||
user_settings,
|
||||
server_mode: ServerMode::None,
|
||||
server: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_exposes_resolved_output_and_explicit_json_state() {
|
||||
let ctx = synthetic_context(true, Printer::Default);
|
||||
|
||||
assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json);
|
||||
assert_eq!(
|
||||
ctx.user_settings().cli.output.verbosity,
|
||||
OutputVerbosity::Verbose
|
||||
);
|
||||
assert!(ctx.explicit_json_requested());
|
||||
assert_eq!(ctx.printer(), Printer::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_dir_override_only_changes_storage_root_in_merged_settings() {
|
||||
let cli_layer = cli_layer_with_json_and_verbose();
|
||||
let base_disk_settings = parse_settings_layer(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.storage]
|
||||
root = "/srv/fabro/default"
|
||||
"#,
|
||||
)
|
||||
.expect("settings fixture should parse");
|
||||
let override_disk_settings = apply_storage_dir_override(
|
||||
base_disk_settings.clone(),
|
||||
Some(std::path::Path::new("/srv/fabro/override")),
|
||||
);
|
||||
|
||||
let (base_settings, base_user_settings) =
|
||||
merge_settings_layer(base_disk_settings, &cli_layer)
|
||||
.expect("base settings should merge");
|
||||
let (connection_settings, connection_user_settings) =
|
||||
merge_settings_layer(override_disk_settings, &cli_layer)
|
||||
.expect("connection settings should merge");
|
||||
|
||||
assert_eq!(base_user_settings, connection_user_settings);
|
||||
assert_eq!(base_user_settings.cli.output.format, OutputFormat::Json);
|
||||
assert_eq!(
|
||||
base_settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.storage.as_ref())
|
||||
.and_then(|storage| storage.root.as_ref())
|
||||
.map(InterpString::as_source),
|
||||
Some("/srv/fabro/default".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
connection_settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.storage.as_ref())
|
||||
.and_then(|storage| storage.root.as_ref())
|
||||
.map(InterpString::as_source),
|
||||
Some("/srv/fabro/override".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() {
|
||||
let json_ctx = synthetic_context(true, Printer::Default);
|
||||
let text_ctx = synthetic_context(false, Printer::Default);
|
||||
|
||||
assert!(json_ctx.require_no_json_override().is_err());
|
||||
assert!(text_ctx.require_no_json_override().is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,28 +6,21 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::ArtifactCpArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client::Client;
|
||||
use crate::shared::{print_json_pretty, split_run_path};
|
||||
|
||||
pub(super) async fn cp_command(
|
||||
args: &ArtifactCpArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) async fn cp_command(args: &ArtifactCpArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
let (run_id_selector, asset_path) = parse_source(&args.source);
|
||||
let (run_id, client, entries) = super::resolve_artifacts(
|
||||
base_ctx,
|
||||
&args.server,
|
||||
run_id_selector,
|
||||
args.node.as_deref(),
|
||||
args.retry,
|
||||
cli_layer,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
@ -64,7 +57,7 @@ pub(super) async fn cp_command(
|
|||
.unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)),
|
||||
);
|
||||
write_artifact_file(&client, &run_id, entry, &dest_file).await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"copied": [{
|
||||
"relative_path": entry.relative_path,
|
||||
|
|
@ -125,7 +118,7 @@ pub(super) async fn cp_command(
|
|||
}
|
||||
}
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "copied": copied }))?;
|
||||
} else {
|
||||
fabro_util::printout!(
|
||||
|
|
|
|||
|
|
@ -1,30 +1,23 @@
|
|||
use anyhow::Result;
|
||||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::ArtifactListArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(super) async fn list_command(
|
||||
args: &ArtifactListArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) async fn list_command(args: &ArtifactListArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
let (_run_id, _client, entries) = super::resolve_artifacts(
|
||||
base_ctx,
|
||||
&args.server,
|
||||
&args.run_id,
|
||||
args.node.as_deref(),
|
||||
args.retry,
|
||||
cli_layer,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&entries)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ mod cp;
|
|||
mod list;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_types::{RunId, StageId};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{ArtifactCommand, ArtifactNamespace, ServerTargetArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
|
|
@ -22,14 +19,13 @@ pub(super) struct ArtifactEntry {
|
|||
}
|
||||
|
||||
pub(super) async fn resolve_artifacts(
|
||||
base_ctx: &CommandContext,
|
||||
server: &ServerTargetArgs,
|
||||
run_selector: &str,
|
||||
node: Option<&str>,
|
||||
retry: Option<u32>,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<(RunId, Client, Vec<ArtifactEntry>)> {
|
||||
let ctx = CommandContext::for_target(server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(server)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(run_selector).await?.run_id;
|
||||
let mut entries = Vec::new();
|
||||
|
|
@ -62,14 +58,9 @@ pub(super) async fn resolve_artifacts(
|
|||
Ok((run_id, client.clone_for_reuse(), entries))
|
||||
}
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: ArtifactNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: ArtifactNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
ArtifactCommand::List(args) => list::list_command(&args, cli, cli_layer, printer).await,
|
||||
ArtifactCommand::Cp(args) => cp::cp_command(&args, cli, cli_layer, printer).await,
|
||||
ArtifactCommand::List(args) => list::list_command(&args, base_ctx).await,
|
||||
ArtifactCommand::Cp(args) => cp::cp_command(&args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ 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::cli::CliLayer;
|
||||
use fabro_util::browser;
|
||||
use fabro_util::printer::Printer;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::args::{AuthLoginArgs, require_no_json_override};
|
||||
use crate::args::AuthLoginArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::user_config;
|
||||
use crate::user_config::ServerTarget;
|
||||
|
|
@ -33,17 +32,13 @@ struct CliTokenSubject {
|
|||
email: String,
|
||||
}
|
||||
|
||||
pub(super) async fn login_command(
|
||||
args: AuthLoginArgs,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
require_no_json_override(process_local_json)?;
|
||||
pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
base_ctx.require_no_json_override()?;
|
||||
let printer = base_ctx.printer();
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (args, cli_layer, printer);
|
||||
let _ = (args, printer);
|
||||
bail!(
|
||||
"CLI OAuth login is not supported on Windows in this release. Use WSL, or use a dev-token server."
|
||||
);
|
||||
|
|
@ -51,8 +46,7 @@ pub(super) async fn login_command(
|
|||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let ctx = CommandContext::base(printer, cli_layer)?;
|
||||
let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?;
|
||||
let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?;
|
||||
let web_url = browser_origin(&target)?;
|
||||
let pkce = fabro_oauth::generate_pkce();
|
||||
let state = fabro_oauth::generate_state();
|
||||
|
|
|
|||
|
|
@ -1,23 +1,16 @@
|
|||
use anyhow::{Result, bail};
|
||||
use fabro_client::{AuthEntry, AuthStore};
|
||||
use fabro_http::header::AUTHORIZATION;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{AuthLogoutArgs, require_no_json_override};
|
||||
use crate::args::AuthLogoutArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::user_config;
|
||||
use crate::user_config::ServerTarget;
|
||||
|
||||
pub(super) async fn logout_command(
|
||||
args: AuthLogoutArgs,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
require_no_json_override(process_local_json)?;
|
||||
pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
base_ctx.require_no_json_override()?;
|
||||
let printer = base_ctx.printer();
|
||||
|
||||
let ctx = CommandContext::base(printer, cli_layer)?;
|
||||
let store = AuthStore::default();
|
||||
if args.all {
|
||||
let entries = store.list()?;
|
||||
|
|
@ -41,7 +34,7 @@ pub(super) async fn logout_command(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?;
|
||||
let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?;
|
||||
let Some(entry) = store.get(&target)? else {
|
||||
fabro_util::printerr!(printer, "Not logged in to {}.", target);
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -3,26 +3,14 @@ mod logout;
|
|||
mod status;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{AuthCommand, AuthNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: AuthNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: AuthNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
AuthCommand::Login(args) => {
|
||||
login::login_command(args, cli_layer, process_local_json, printer).await
|
||||
}
|
||||
AuthCommand::Logout(args) => {
|
||||
logout::logout_command(args, cli_layer, process_local_json, printer).await
|
||||
}
|
||||
AuthCommand::Status(args) => {
|
||||
status::status_command(&args, cli_layer, process_local_json, printer)
|
||||
}
|
||||
AuthCommand::Login(args) => login::login_command(args, base_ctx).await,
|
||||
AuthCommand::Logout(args) => logout::logout_command(args, base_ctx).await,
|
||||
AuthCommand::Status(args) => status::status_command(&args, base_ctx),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_client::{AuthEntry, AuthStore};
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format};
|
||||
use fabro_util::printer::Printer;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::args::AuthStatusArgs;
|
||||
|
|
@ -40,13 +38,8 @@ struct StatusOutput {
|
|||
dev_token: &'static str,
|
||||
}
|
||||
|
||||
pub(super) fn status_command(
|
||||
args: &AuthStatusArgs,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::base(printer, cli_layer)?;
|
||||
pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let printer = ctx.printer();
|
||||
let store = AuthStore::default();
|
||||
let now = Utc::now();
|
||||
let rows = if args.server.as_deref().is_some() {
|
||||
|
|
@ -61,7 +54,7 @@ pub(super) fn status_command(
|
|||
"not_set"
|
||||
};
|
||||
|
||||
if process_local_json {
|
||||
if ctx.explicit_json_requested() {
|
||||
print_json_pretty(&StatusOutput {
|
||||
servers: rows,
|
||||
dev_token,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ use std::io::Write;
|
|||
|
||||
use fabro_api::types::ServerSettings;
|
||||
use fabro_config::UserSettings;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::args::SettingsArgs;
|
||||
|
|
@ -26,29 +23,17 @@ struct RenderedConfig {
|
|||
server: ServerSettings,
|
||||
}
|
||||
|
||||
async fn rendered_config(
|
||||
args: &SettingsArgs,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let user = fabro_config::UserSettings::resolve()?;
|
||||
let server = ctx
|
||||
.server()
|
||||
.await?
|
||||
.retrieve_resolved_server_settings()
|
||||
.await?;
|
||||
serde_json::to_value(RenderedConfig { user, server }).map_err(Into::into)
|
||||
}
|
||||
let config = serde_json::to_value(RenderedConfig { user, server })?;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
args: &SettingsArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = Box::pin(rendered_config(args, cli_layer, printer)).await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&config)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ 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::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
pub(crate) use fabro_util::check_report::{
|
||||
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
|
||||
};
|
||||
|
|
@ -142,13 +140,12 @@ fn render_report(report: &CheckReport, styles: &Styles, verbose: bool, printer:
|
|||
|
||||
pub(crate) async fn run_doctor(
|
||||
args: &DoctorArgs,
|
||||
verbose: bool,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<i32, anyhow::Error> {
|
||||
let verbose = args.verbose || base_ctx.verbose();
|
||||
let printer = base_ctx.printer();
|
||||
let styles = Styles::detect_stdout();
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let json = base_ctx.json_output();
|
||||
let spinner = if json {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -179,7 +176,7 @@ pub(crate) async fn run_doctor(
|
|||
}],
|
||||
};
|
||||
|
||||
let ctx = match CommandContext::for_target(&args.target, printer, cli_layer) {
|
||||
let ctx = match base_ctx.with_target(&args.target) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
report.sections.push(CheckSection {
|
||||
|
|
|
|||
|
|
@ -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,34 +11,27 @@ use bytes::Bytes;
|
|||
#[cfg(test)]
|
||||
use fabro_store::{ArtifactStore, RunDatabase};
|
||||
use fabro_store::{EventEnvelope, RunProjection, StageId};
|
||||
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: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(crate) async fn run(args: &DumpArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
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?;
|
||||
let source = ServerDumpSource::new(client.as_ref(), &run_id);
|
||||
let file_count = export_run_from_source(&source, &state, &args.output).await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"run_id": run_id,
|
||||
"output_dir": absolute_or_current(&args.output),
|
||||
|
|
@ -222,7 +215,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 +241,7 @@ async fn write_run_dump(
|
|||
output_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
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?;
|
||||
|
|
@ -13,15 +13,15 @@ use fabro_llm::types::{
|
|||
FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts,
|
||||
};
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
|
||||
use fabro_types::settings::run::McpEntryLayer;
|
||||
use fabro_types::settings::{CliNamespace, InterpString};
|
||||
use fabro_util::exit::{ErrorExt, ExitClass};
|
||||
use fabro_util::printer::Printer;
|
||||
use futures::stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::args::ExecArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::{server_client, user_config};
|
||||
|
||||
fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
|
||||
|
|
@ -356,14 +356,11 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
mut args: ExecArgs,
|
||||
cli: &CliNamespace,
|
||||
_printer: Printer,
|
||||
) -> AnyResult<()> {
|
||||
pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResult<()> {
|
||||
use fabro_agent::cli::PermissionLevel as AgentPermissionLevel;
|
||||
use fabro_types::settings::run::AgentPermissions;
|
||||
|
||||
let cli = &ctx.user_settings().cli;
|
||||
let raw_settings = user_config::load_settings()?;
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = crate::sleep_inhibitor::guard(cli.exec.prevent_idle_sleep);
|
||||
|
|
|
|||
|
|
@ -13,13 +13,11 @@ use anyhow::{Context, bail};
|
|||
use fabro_api::types;
|
||||
use fabro_config::load::load_settings_user;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_types::settings::{CliNamespace, SettingsLayer};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::args::{GraphArgs, GraphDirection, require_no_json_override};
|
||||
use crate::args::{GraphArgs, GraphDirection};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::run::output::api_diagnostics_to_local;
|
||||
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
|
||||
|
|
@ -28,16 +26,14 @@ use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, r
|
|||
pub(crate) async fn run(
|
||||
args: &GraphArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> anyhow::Result<()> {
|
||||
if process_local_json && args.output.is_none() {
|
||||
require_no_json_override(process_local_json)?;
|
||||
if args.output.is_none() {
|
||||
base_ctx.require_no_json_override()?;
|
||||
}
|
||||
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
|
|
@ -73,7 +69,7 @@ pub(crate) async fn run(
|
|||
if let Some(ref output_path) = args.output {
|
||||
std::fs::write(output_path, &rendered)
|
||||
.with_context(|| format!("writing rendered graph to {}", output_path.display()))?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"path": absolute_or_current(output_path),
|
||||
"format": args.format.to_string(),
|
||||
|
|
|
|||
|
|
@ -26,15 +26,14 @@ use fabro_config::daemon::ServerDaemon;
|
|||
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
|
||||
use fabro_config::{Storage, envfile};
|
||||
use fabro_install::{
|
||||
InstallListenConfig, generate_jwt_keypair, merge_server_settings as merge_server_settings_impl,
|
||||
write_github_app_settings, write_token_settings,
|
||||
InstallListenConfig, PendingSettingsWrite, merge_server_settings as merge_server_settings_impl,
|
||||
persist_install_outputs_direct, write_github_app_settings, write_token_settings,
|
||||
};
|
||||
use fabro_model::Provider;
|
||||
use fabro_server::serve;
|
||||
use fabro_store::ArtifactStore;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::server::ServerAuthMethod;
|
||||
use fabro_types::settings::{CliNamespace, SettingsLayer};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
|
|
@ -52,6 +51,7 @@ use crate::args::{
|
|||
DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs,
|
||||
InstallNonInteractiveArgs, ServerTargetArgs,
|
||||
};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::server::{start, stop};
|
||||
use crate::gh::GhCli;
|
||||
use crate::shared::provider_auth::{
|
||||
|
|
@ -883,13 +883,6 @@ enum PendingGitHubSettings {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PendingSettingsWrite<'a> {
|
||||
path: &'a Path,
|
||||
contents: &'a str,
|
||||
previous_contents: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn setup_github_app(
|
||||
s: &Styles,
|
||||
web_url: &str,
|
||||
|
|
@ -1148,15 +1141,24 @@ fn credential_secret_request(credential: &AuthCredential) -> Result<CreateSecret
|
|||
})
|
||||
}
|
||||
|
||||
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
fn server_env_updates(secrets: &[(String, String)]) -> Vec<envfile::EnvFileUpdate> {
|
||||
secrets
|
||||
.iter()
|
||||
.map(|(key, value)| envfile::EnvFileUpdate {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
comment: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
|
||||
envfile::merge_env_file(&env_path, secrets.iter().cloned())
|
||||
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
|
||||
Ok(())
|
||||
fn server_env_removals(keys: &[&'static str]) -> Vec<envfile::EnvFileRemoval> {
|
||||
keys.iter()
|
||||
.map(|key| envfile::EnvFileRemoval {
|
||||
key: (*key).to_string(),
|
||||
comment: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn persist_install_outputs(
|
||||
|
|
@ -1221,20 +1223,15 @@ fn persist_github_install_changes(
|
|||
let previous_vault = std::fs::read_to_string(&vault_path).ok();
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
let mut server_env = envfile::read_env_file(&server_env_path)
|
||||
.with_context(|| format!("reading env file {}", server_env_path.display()))?;
|
||||
for key in &writes.server_env_remove {
|
||||
server_env.remove(*key);
|
||||
}
|
||||
for (key, value) in &writes.server_env_set {
|
||||
server_env.insert(key.clone(), value.clone());
|
||||
}
|
||||
if server_env.is_empty() {
|
||||
restore_optional_file(&server_env_path, None)?;
|
||||
} else {
|
||||
envfile::write_env_file(&server_env_path, &server_env)
|
||||
.with_context(|| format!("writing env file {}", server_env_path.display()))?;
|
||||
}
|
||||
let server_env_writes = server_env_updates(&writes.server_env_set);
|
||||
let server_env_removals = server_env_removals(&writes.server_env_remove);
|
||||
persist_install_outputs_direct(
|
||||
storage_dir,
|
||||
&server_env_writes,
|
||||
&server_env_removals,
|
||||
&[],
|
||||
Some(&writes.settings_write),
|
||||
)?;
|
||||
|
||||
let mut vault = Vault::load(vault_path.clone()).map_err(anyhow::Error::from)?;
|
||||
for key in &writes.vault_remove {
|
||||
|
|
@ -1249,14 +1246,6 @@ fn persist_github_install_changes(
|
|||
.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
|
||||
std::fs::write(writes.settings_write.path, writes.settings_write.contents).with_context(
|
||||
|| {
|
||||
format!(
|
||||
"writing settings file {}",
|
||||
writes.settings_write.path.display()
|
||||
)
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
|
|
@ -1293,12 +1282,16 @@ async fn persist_install_outputs_with_settings(
|
|||
connect_server: impl for<'a> Fn(&'a Path) -> BoxFuture<'a, Result<server_client::Client>>,
|
||||
stop_server: impl for<'a> Fn(&'a Path, Duration) -> BoxFuture<'a, bool>,
|
||||
) -> Result<()> {
|
||||
persist_server_env_secrets(storage_dir, server_env_secrets)?;
|
||||
|
||||
if let Some(write) = settings_write {
|
||||
std::fs::write(write.path, write.contents)
|
||||
.with_context(|| format!("writing settings file {}", write.path.display()))?;
|
||||
}
|
||||
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
|
||||
let previous_server_env = std::fs::read_to_string(&server_env_path).ok();
|
||||
let settings_write_ref = settings_write.as_ref();
|
||||
persist_install_outputs_direct(
|
||||
storage_dir,
|
||||
&server_env_updates(server_env_secrets),
|
||||
&[],
|
||||
&[],
|
||||
settings_write_ref,
|
||||
)?;
|
||||
|
||||
let persist_result = persist_vault_secrets_with(
|
||||
storage_dir,
|
||||
|
|
@ -1310,6 +1303,7 @@ async fn persist_install_outputs_with_settings(
|
|||
.await;
|
||||
|
||||
if let Err(err) = persist_result {
|
||||
restore_optional_file(&server_env_path, previous_server_env.as_deref())?;
|
||||
if let Some(write) = settings_write {
|
||||
match write.previous_contents {
|
||||
Some(previous) => std::fs::write(write.path, previous)
|
||||
|
|
@ -1415,15 +1409,12 @@ where
|
|||
pub(crate) async fn execute(
|
||||
args: &InstallArgs,
|
||||
command: Option<InstallCommand>,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
None => run_install(args, cli, cli_layer, process_local_json, printer).await,
|
||||
None => run_install(args, ctx).await,
|
||||
Some(InstallCommand::Github(github_args)) => {
|
||||
run_install_github_command(args, &github_args, cli, process_local_json, printer).await
|
||||
run_install_github_command(args, &github_args, ctx).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1431,16 +1422,20 @@ pub(crate) async fn execute(
|
|||
async fn run_install_github_command(
|
||||
args: &InstallArgs,
|
||||
github_args: &InstallGithubArgs,
|
||||
cli: &CliNamespace,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
if process_local_json && !args.non_interactive {
|
||||
let json = ctx.json_output();
|
||||
if ctx.explicit_json_requested() && !args.non_interactive {
|
||||
bail!("--json is only supported for install with --non-interactive");
|
||||
}
|
||||
|
||||
let result = Box::pin(run_install_github_inner(args, github_args, json, printer)).await;
|
||||
let result = Box::pin(run_install_github_inner(
|
||||
args,
|
||||
github_args,
|
||||
json,
|
||||
ctx.printer(),
|
||||
))
|
||||
.await;
|
||||
if json {
|
||||
let emit_result = match &result {
|
||||
Ok(()) => emit_install_json_event(&install_complete_event()),
|
||||
|
|
@ -1592,19 +1587,13 @@ async fn run_install_github_inner(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn run_install(
|
||||
args: &InstallArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
if process_local_json && !args.non_interactive {
|
||||
pub(crate) async fn run_install(args: &InstallArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let json = ctx.json_output();
|
||||
if ctx.explicit_json_requested() && !args.non_interactive {
|
||||
bail!("--json is only supported for install with --non-interactive");
|
||||
}
|
||||
|
||||
let result = Box::pin(run_install_inner(args, cli, cli_layer, printer)).await;
|
||||
let result = Box::pin(run_install_inner(args, ctx)).await;
|
||||
if json {
|
||||
let emit_result = match &result {
|
||||
Ok(()) => emit_install_json_event(&install_complete_event()),
|
||||
|
|
@ -1618,13 +1607,10 @@ pub(crate) async fn run_install(
|
|||
result
|
||||
}
|
||||
|
||||
async fn run_install_inner(
|
||||
args: &InstallArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let _cli = &ctx.user_settings().cli;
|
||||
let printer = ctx.printer();
|
||||
let json = ctx.json_output();
|
||||
let web_url = &args.web_url;
|
||||
let s = Styles::detect_stderr();
|
||||
let emoji = console::Emoji("⚒️ ", "");
|
||||
|
|
@ -1807,13 +1793,6 @@ async fn run_install_inner(
|
|||
s.green.apply_to("✔")
|
||||
);
|
||||
|
||||
let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?;
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} Ed25519 JWT keypair generated",
|
||||
s.green.apply_to("✔")
|
||||
);
|
||||
|
||||
let dev_token = if fabro_config::dev_token_auth_enabled(&install_settings) {
|
||||
let token = dev_token::read_or_mint_dev_token_for_install(
|
||||
&fabro_util::Home::from_env().dev_token_path(),
|
||||
|
|
@ -1834,14 +1813,7 @@ async fn run_install_inner(
|
|||
None
|
||||
};
|
||||
|
||||
let jwt_private_b64 = BASE64_STANDARD.encode(jwt_private_pem.as_bytes());
|
||||
let jwt_public_b64 = BASE64_STANDARD.encode(jwt_public_pem.as_bytes());
|
||||
|
||||
let mut generated_server_env_pairs = vec![
|
||||
("FABRO_JWT_PRIVATE_KEY".to_string(), jwt_private_b64),
|
||||
("FABRO_JWT_PUBLIC_KEY".to_string(), jwt_public_b64),
|
||||
("SESSION_SECRET".to_string(), session_secret),
|
||||
];
|
||||
let mut generated_server_env_pairs = vec![("SESSION_SECRET".to_string(), session_secret)];
|
||||
if let Some(token) = dev_token {
|
||||
generated_server_env_pairs.push(("FABRO_DEV_TOKEN".to_string(), token));
|
||||
}
|
||||
|
|
@ -1941,7 +1913,7 @@ async fn run_install_inner(
|
|||
target: ServerTargetArgs::default(),
|
||||
verbose: false,
|
||||
};
|
||||
doctor::run_doctor(&doctor_args, false, cli, cli_layer, printer).await
|
||||
doctor::run_doctor(&doctor_args, ctx).await
|
||||
},
|
||||
)
|
||||
.await?
|
||||
|
|
@ -2026,39 +1998,6 @@ mod tests {
|
|||
assert!(secret.chars().all(|c| !c.is_ascii_uppercase()));
|
||||
}
|
||||
|
||||
// -- JWT keypair --
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_keypair_private_pem_header() {
|
||||
let (private, _) = generate_jwt_keypair().unwrap();
|
||||
assert!(
|
||||
private.starts_with("-----BEGIN PRIVATE KEY-----"),
|
||||
"private PEM: {private}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_keypair_public_pem_header() {
|
||||
let (_, public) = generate_jwt_keypair().unwrap();
|
||||
assert!(
|
||||
public.starts_with("-----BEGIN PUBLIC KEY-----"),
|
||||
"public PEM: {public}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_keypair_public_parses() {
|
||||
let (_, public) = generate_jwt_keypair().unwrap();
|
||||
jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwt_keypair_private_parses() {
|
||||
let (private, _) = generate_jwt_keypair().unwrap();
|
||||
jsonwebtoken::EncodingKey::from_ed_pem(private.as_bytes())
|
||||
.expect("private key should parse");
|
||||
}
|
||||
|
||||
// -- Config TOML generation --
|
||||
|
||||
#[test]
|
||||
|
|
@ -2511,11 +2450,8 @@ client_id = "client-id"
|
|||
#[tokio::test]
|
||||
async fn persist_install_outputs_persists_vault_secrets_via_server_when_autostarting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server_env_pairs = vec![
|
||||
("SESSION_SECRET".to_string(), "session".to_string()),
|
||||
("FABRO_JWT_PUBLIC_KEY".to_string(), "public-key".to_string()),
|
||||
];
|
||||
let vault_secrets = vec![
|
||||
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
|
||||
let vault_secrets = [
|
||||
CreateSecretRequest {
|
||||
name: "GITHUB_TOKEN".to_string(),
|
||||
value: "gh-token".to_string(),
|
||||
|
|
@ -2549,7 +2485,8 @@ client_id = "client-id"
|
|||
.await;
|
||||
let stop_called = Arc::new(AtomicBool::new(false));
|
||||
|
||||
persist_server_env_secrets(dir.path(), &server_env_pairs).unwrap();
|
||||
let env_path = Storage::new(dir.path()).runtime_directory().env_path();
|
||||
envfile::merge_env_file(&env_path, server_env_pairs.iter().cloned()).unwrap();
|
||||
persist_vault_secrets_with(
|
||||
dir.path(),
|
||||
&vault_secrets,
|
||||
|
|
@ -2576,7 +2513,6 @@ client_id = "client-id"
|
|||
std::fs::read_to_string(Storage::new(dir.path()).runtime_directory().env_path())
|
||||
.unwrap();
|
||||
assert!(server_env.contains("SESSION_SECRET=session"));
|
||||
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key"));
|
||||
assert_eq!(created.calls_async().await, 2);
|
||||
assert!(stop_called.load(Ordering::SeqCst));
|
||||
assert!(!Storage::new(dir.path()).secrets_path().exists());
|
||||
|
|
@ -2585,7 +2521,7 @@ client_id = "client-id"
|
|||
#[tokio::test]
|
||||
async fn persist_vault_secrets_with_leaves_running_server_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault_secrets = vec![CreateSecretRequest {
|
||||
let vault_secrets = [CreateSecretRequest {
|
||||
name: "GITHUB_TOKEN".to_string(),
|
||||
value: "gh-token".to_string(),
|
||||
type_: ApiSecretType::Environment,
|
||||
|
|
@ -2793,10 +2729,10 @@ client_id = "client-id"
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_install_outputs_with_settings_does_not_write_settings_on_secret_failure() {
|
||||
async fn persist_install_outputs_with_settings_rolls_back_new_files_on_secret_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
|
||||
let vault_secrets = vec![CreateSecretRequest {
|
||||
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
|
||||
let vault_secrets = [CreateSecretRequest {
|
||||
name: "GITHUB_CLI_TOKEN".to_string(),
|
||||
value: "gh-token".to_string(),
|
||||
type_: ApiSecretType::Environment,
|
||||
|
|
@ -2831,7 +2767,7 @@ client_id = "client-id"
|
|||
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
Storage::new(dir.path())
|
||||
!Storage::new(dir.path())
|
||||
.runtime_directory()
|
||||
.env_path()
|
||||
.exists()
|
||||
|
|
@ -2843,8 +2779,8 @@ client_id = "client-id"
|
|||
#[tokio::test]
|
||||
async fn persist_install_outputs_with_settings_restores_previous_contents_on_secret_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
|
||||
let vault_secrets = vec![CreateSecretRequest {
|
||||
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
|
||||
let vault_secrets = [CreateSecretRequest {
|
||||
name: "GITHUB_CLI_TOKEN".to_string(),
|
||||
value: "gh-token".to_string(),
|
||||
type_: ApiSecretType::Environment,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ 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::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -42,19 +39,17 @@ struct ModelTestOutput {
|
|||
|
||||
pub(crate) async fn execute(
|
||||
command: Option<ModelsCommand>,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let command = command.unwrap_or_default();
|
||||
let target_args = match &command {
|
||||
ModelsCommand::List(args) => &args.target,
|
||||
ModelsCommand::Test(args) => &args.target,
|
||||
};
|
||||
let ctx = CommandContext::for_target(target_args, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(target_args)?;
|
||||
let server = ctx.server().await?;
|
||||
|
||||
run_models(command, &server, cli.output.format == OutputFormat::Json).await
|
||||
run_models(command, &server, ctx.json_output()).await
|
||||
}
|
||||
|
||||
fn format_context_window(tokens: i64) -> String {
|
||||
|
|
|
|||
|
|
@ -11,13 +11,11 @@ use std::io::Write;
|
|||
|
||||
use fabro_config::project::resolve_workflow;
|
||||
use fabro_graphviz::parser::parse_ast;
|
||||
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: &CliNamespace, _printer: Printer) -> anyhow::Result<()> {
|
||||
pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> {
|
||||
let stdout = std::io::stdout();
|
||||
run_to(args, stdout.lock())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrCloseArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(super) async fn close_command(
|
||||
args: PrCloseArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let (record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?;
|
||||
pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let (ctx, record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, base_ctx).await?;
|
||||
|
||||
let creds = super::load_github_credentials_required(cli_layer, printer)?;
|
||||
let creds = super::load_github_credentials_required(&ctx)?;
|
||||
|
||||
fabro_github::close_pull_request(
|
||||
&creds,
|
||||
|
|
@ -29,13 +22,18 @@ pub(super) async fn close_command(
|
|||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request");
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"number": record.number,
|
||||
"html_url": record.html_url,
|
||||
}))?;
|
||||
} else {
|
||||
fabro_util::printout!(printer, "Closed #{} ({})", record.number, record.html_url);
|
||||
fabro_util::printout!(
|
||||
ctx.printer(),
|
||||
"Closed #{} ({})",
|
||||
record.number,
|
||||
record.html_url
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ 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::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_vault::Vault;
|
||||
use fabro_workflow::outcome::StageStatus;
|
||||
use fabro_workflow::pull_request::maybe_open_pull_request;
|
||||
|
|
@ -16,7 +13,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;
|
||||
|
|
@ -25,13 +22,9 @@ use crate::user_config;
|
|||
deprecated,
|
||||
reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side"
|
||||
)]
|
||||
pub(super) async fn create_command(
|
||||
args: PrCreateArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
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 +79,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_layer, printer)?;
|
||||
let creds = super::load_github_credentials_required(base_ctx)?;
|
||||
|
||||
let branch_found = fabro_github::branch_exists(
|
||||
&creds,
|
||||
|
|
@ -137,14 +130,14 @@ pub(super) async fn create_command(
|
|||
match pull_request {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&record)?;
|
||||
} else {
|
||||
fabro_util::printout!(printer, "{}", record.html_url);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::Value::Null)?;
|
||||
} else {
|
||||
fabro_util::printout!(printer, "No pull request created (empty diff).");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use futures::future::join_all;
|
||||
use serde::Serialize;
|
||||
|
|
@ -23,13 +20,9 @@ struct PrRow {
|
|||
url: String,
|
||||
}
|
||||
|
||||
pub(super) async fn list_command(
|
||||
args: PrListArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(super) async fn list_command(args: PrListArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
|
@ -42,7 +35,7 @@ pub(super) async fn list_command(
|
|||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&Vec::<PrRow>::new())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -50,7 +43,7 @@ pub(super) async fn list_command(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = super::load_github_credentials_required(cli_layer, printer)?;
|
||||
let creds = super::load_github_credentials_required(base_ctx)?;
|
||||
|
||||
let futures: Vec<_> = entries
|
||||
.iter()
|
||||
|
|
@ -104,7 +97,7 @@ pub(super) async fn list_command(
|
|||
.collect()
|
||||
};
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&rows)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrMergeArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(super) async fn merge_command(
|
||||
args: PrMergeArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let (record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?;
|
||||
pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let (ctx, record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, base_ctx).await?;
|
||||
|
||||
let creds = super::load_github_credentials_required(cli_layer, printer)?;
|
||||
let creds = super::load_github_credentials_required(&ctx)?;
|
||||
|
||||
fabro_github::merge_pull_request(
|
||||
&creds,
|
||||
|
|
@ -30,14 +23,19 @@ pub(super) async fn merge_command(
|
|||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request");
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"number": record.number,
|
||||
"html_url": record.html_url,
|
||||
"method": args.method,
|
||||
}))?;
|
||||
} else {
|
||||
fabro_util::printout!(printer, "Merged #{} ({})", record.number, record.html_url);
|
||||
fabro_util::printout!(
|
||||
ctx.printer(),
|
||||
"Merged #{} ({})",
|
||||
record.number,
|
||||
record.html_url
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ use anyhow::{Context, Result, anyhow};
|
|||
use fabro_config::Storage;
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_types::PullRequestRecord;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_types::settings::{CliNamespace, InterpString};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_types::settings::InterpString;
|
||||
|
||||
use crate::args::{PrCommand, PrNamespace, ServerTargetArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
|
|
@ -20,20 +18,13 @@ use crate::user_config;
|
|||
const GITHUB_CREDENTIALS_REQUIRED: &str =
|
||||
"GitHub credentials required — run `fabro install` or set GITHUB_TOKEN";
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: PrNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
PrCommand::Create(args) => {
|
||||
Box::pin(create::create_command(args, cli, cli_layer, printer)).await
|
||||
}
|
||||
PrCommand::List(args) => list::list_command(args, cli, cli_layer, printer).await,
|
||||
PrCommand::View(args) => view::view_command(args, cli, cli_layer, printer).await,
|
||||
PrCommand::Merge(args) => merge::merge_command(args, cli, cli_layer, printer).await,
|
||||
PrCommand::Close(args) => close::close_command(args, cli, cli_layer, printer).await,
|
||||
PrCommand::Create(args) => Box::pin(create::create_command(args, base_ctx)).await,
|
||||
PrCommand::List(args) => list::list_command(args, base_ctx).await,
|
||||
PrCommand::View(args) => view::view_command(args, base_ctx).await,
|
||||
PrCommand::Merge(args) => merge::merge_command(args, base_ctx).await,
|
||||
PrCommand::Close(args) => close::close_command(args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,14 +32,10 @@ pub(crate) async fn dispatch(
|
|||
deprecated,
|
||||
reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side"
|
||||
)]
|
||||
fn load_github_credentials_required(
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<GitHubCredentials> {
|
||||
let ctx = CommandContext::base(printer, cli_layer)?;
|
||||
let server_settings = fabro_config::ServerSettings::from_layer(ctx.machine_settings())
|
||||
fn load_github_credentials_required(base_ctx: &CommandContext) -> Result<GitHubCredentials> {
|
||||
let server_settings = fabro_config::ServerSettings::from_layer(base_ctx.machine_settings())
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let vault = user_config::storage_dir(ctx.machine_settings())
|
||||
let vault = user_config::storage_dir(base_ctx.machine_settings())
|
||||
.ok()
|
||||
.and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok());
|
||||
let creds = build_github_credentials(
|
||||
|
|
@ -70,15 +57,14 @@ fn load_github_credentials_required(
|
|||
pub(crate) async fn load_pr_record(
|
||||
server: &ServerTargetArgs,
|
||||
run_id: &str,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<(PullRequestRecord, fabro_types::RunId)> {
|
||||
let ctx = CommandContext::for_target(server, printer, cli_layer)?;
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<(CommandContext, PullRequestRecord, fabro_types::RunId)> {
|
||||
let ctx = base_ctx.with_target(server)?;
|
||||
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?;
|
||||
let record = state.pull_request.with_context(|| {
|
||||
format!("No pull request found in store. Create one first with: fabro pr create {run_id}")
|
||||
})?;
|
||||
Ok((record, run_id))
|
||||
Ok((ctx, record, run_id))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrViewArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(super) async fn view_command(
|
||||
args: PrViewArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let (record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?;
|
||||
pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let (ctx, record, _run_id) =
|
||||
super::load_pr_record(&args.server, &args.run_id, base_ctx).await?;
|
||||
|
||||
let creds = super::load_github_credentials_required(cli_layer, printer)?;
|
||||
let creds = super::load_github_credentials_required(&ctx)?;
|
||||
|
||||
let detail = fabro_github::get_pull_request(
|
||||
&creds,
|
||||
|
|
@ -30,11 +23,12 @@ pub(super) async fn view_command(
|
|||
|
||||
info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request");
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&detail)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let printer = ctx.printer();
|
||||
fabro_util::printout!(printer, "#{} {}", detail.number, detail.title);
|
||||
let state_display = if detail.draft { "draft" } else { &detail.state };
|
||||
fabro_util::printout!(printer, "State: {state_display}");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use anyhow::bail;
|
||||
use fabro_config::load::load_settings_user;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::PreflightArgs;
|
||||
|
|
@ -17,13 +14,12 @@ use crate::shared::print_json_pretty;
|
|||
|
||||
pub(crate) async fn execute(
|
||||
mut args: PreflightArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> anyhow::Result<()> {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
args.verbose = args.verbose || ctx.verbose();
|
||||
|
||||
let manifest = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
|
|
@ -38,7 +34,7 @@ pub(crate) async fn execute(
|
|||
let response = client.run_preflight(manifest.manifest).await?;
|
||||
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&response)?;
|
||||
} else {
|
||||
print_preflight_workflow_summary(
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
use anyhow::Result;
|
||||
use fabro_api::types;
|
||||
use fabro_auth::credential_id_for;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::{ProviderLoginArgs, require_no_json_override};
|
||||
use crate::args::ProviderLoginArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::provider_auth;
|
||||
|
||||
pub(super) async fn login_command(
|
||||
args: ProviderLoginArgs,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
require_no_json_override(process_local_json)?;
|
||||
base_ctx.require_no_json_override()?;
|
||||
let printer = base_ctx.printer();
|
||||
let s = Styles::detect_stderr();
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let server = ctx.server().await?;
|
||||
let credential = if args.api_key_stdin {
|
||||
provider_auth::authenticate_provider_with_api_key_source(
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
mod login;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{ProviderCommand, ProviderNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: ProviderNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: ProviderNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
ProviderCommand::Login(args) => {
|
||||
login::login_command(args, cli_layer, process_local_json, printer).await
|
||||
}
|
||||
ProviderCommand::Login(args) => login::login_command(args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result<Vec<String>> {
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) fn run_deinit(base_ctx: &CommandContext) -> Result<Vec<String>> {
|
||||
let printer = base_ctx.printer();
|
||||
let repo_root = super::init::git_repo_root()?;
|
||||
let mut removed = Vec::new();
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result<Vec<Str
|
|||
std::fs::remove_dir_all(&fabro_dir)
|
||||
.with_context(|| format!("failed to remove {}", fabro_dir.display()))?;
|
||||
removed.push(".fabro/".to_string());
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
|
|
@ -29,7 +29,7 @@ pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result<Vec<Str
|
|||
);
|
||||
}
|
||||
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"\n{}",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
|
|
@ -36,10 +33,9 @@ pub(super) fn git_repo_root() -> Result<PathBuf> {
|
|||
|
||||
pub(crate) async fn run_init(
|
||||
args: &RepoInitArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<Vec<String>> {
|
||||
let printer = base_ctx.printer();
|
||||
let repo_root = spawn_blocking(git_repo_root)
|
||||
.await
|
||||
.context("git repo root task panicked")??;
|
||||
|
|
@ -79,7 +75,7 @@ draft = true
|
|||
let green = console::Style::new().green();
|
||||
let bold = console::Style::new().bold();
|
||||
let dim = console::Style::new().dim();
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
|
|
@ -112,7 +108,7 @@ draft = true
|
|||
)
|
||||
.with_context(|| format!("failed to write {}", dot_path.display()))?;
|
||||
created.push(".fabro/workflows/hello/workflow.fabro".to_string());
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
|
|
@ -129,7 +125,7 @@ draft = true
|
|||
)
|
||||
.with_context(|| format!("failed to write {}", toml_path.display()))?;
|
||||
created.push(".fabro/workflows/hello/workflow.toml".to_string());
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
|
|
@ -138,7 +134,7 @@ draft = true
|
|||
);
|
||||
}
|
||||
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
if !base_ctx.json_output() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"\n{} Run a workflow with:\n\n {}",
|
||||
|
|
@ -150,18 +146,15 @@ draft = true
|
|||
);
|
||||
}
|
||||
|
||||
if cli.output.format != OutputFormat::Json {
|
||||
check_github_app_installation(&args.target, cli_layer, printer).await;
|
||||
if !base_ctx.json_output() {
|
||||
check_github_app_installation(&args.target, base_ctx).await;
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn check_github_app_installation(
|
||||
target: &ServerTargetArgs,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) {
|
||||
async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &CommandContext) {
|
||||
let printer = base_ctx.printer();
|
||||
// Get the git remote origin URL
|
||||
let output = match TokioCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
|
|
@ -199,7 +192,7 @@ async fn check_github_app_installation(
|
|||
return; // Not a GitHub repo — skip silently
|
||||
};
|
||||
|
||||
let ctx = match CommandContext::for_target(target, printer, cli_layer) {
|
||||
let ctx = match base_ctx.with_target(target) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
fabro_util::printerr!(
|
||||
|
|
|
|||
|
|
@ -2,30 +2,23 @@ pub(crate) mod deinit;
|
|||
pub(crate) mod init;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{RepoCommand, RepoNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: RepoNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: RepoNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
RepoCommand::Init(args) => {
|
||||
let created = init::run_init(&args, cli, cli_layer, printer).await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
let created = init::run_init(&args, base_ctx).await?;
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "created": created }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RepoCommand::Deinit => {
|
||||
let removed = deinit::run_deinit(cli, printer)?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
let removed = deinit::run_deinit(base_ctx)?;
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "removed": removed }))?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,36 +1,19 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::load_settings_with_storage_dir;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
mut args: RunArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
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 printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
args.verbose = args.verbose || ctx.verbose();
|
||||
|
||||
let quiet = args.detach;
|
||||
let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep;
|
||||
let created_run = Box::pin(super::create::create_run(
|
||||
&ctx,
|
||||
&args,
|
||||
cli_defaults,
|
||||
styles,
|
||||
quiet,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
let created_run = Box::pin(super::create::create_run(&ctx, &args, styles, quiet)).await?;
|
||||
|
||||
if !quiet {
|
||||
fabro_util::printerr!(
|
||||
|
|
@ -50,7 +33,7 @@ pub(crate) async fn execute(
|
|||
let client = ctx.server().await?;
|
||||
super::start::start_run_with_client(&client, &created_run.run_id, false).await?;
|
||||
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let json = ctx.json_output();
|
||||
if args.detach {
|
||||
if json {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?;
|
||||
|
|
@ -64,7 +47,7 @@ pub(crate) async fn execute(
|
|||
true,
|
||||
styles,
|
||||
json,
|
||||
ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose,
|
||||
ctx.verbose(),
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
|
|
@ -26,12 +23,7 @@ enum CopyDirection {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) async fn cp_command(
|
||||
args: CpArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn cp_command(args: CpArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let direction = parse_direction(&args.src, &args.dst)?;
|
||||
|
||||
match direction {
|
||||
|
|
@ -41,7 +33,7 @@ pub(crate) async fn cp_command(
|
|||
local_path,
|
||||
} => {
|
||||
let (client, run_id) =
|
||||
resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?;
|
||||
resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?;
|
||||
|
||||
let file_count = if args.recursive {
|
||||
Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?)
|
||||
|
|
@ -51,7 +43,7 @@ pub(crate) async fn cp_command(
|
|||
None
|
||||
};
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
let mut value = serde_json::json!({
|
||||
"direction": "download",
|
||||
"recursive": args.recursive,
|
||||
|
|
@ -72,7 +64,7 @@ pub(crate) async fn cp_command(
|
|||
remote_path,
|
||||
} => {
|
||||
let (client, run_id) =
|
||||
resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?;
|
||||
resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?;
|
||||
|
||||
let file_count = if args.recursive {
|
||||
Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?)
|
||||
|
|
@ -82,7 +74,7 @@ pub(crate) async fn cp_command(
|
|||
None
|
||||
};
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
let mut value = serde_json::json!({
|
||||
"direction": "upload",
|
||||
"recursive": args.recursive,
|
||||
|
|
@ -124,12 +116,11 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
|||
}
|
||||
|
||||
async fn resolve_client_and_run_id(
|
||||
base_ctx: &CommandContext,
|
||||
server: &ServerTargetArgs,
|
||||
run_prefix: &str,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<(Client, fabro_types::RunId)> {
|
||||
let ctx = CommandContext::for_target(server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(server)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(run_prefix).await?.run_id;
|
||||
Ok((client.clone_for_reuse(), run_id))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use fabro_config::load::load_settings_user;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary};
|
||||
|
|
@ -22,10 +20,8 @@ pub(crate) struct CreatedRun {
|
|||
pub(crate) async fn create_run(
|
||||
ctx: &CommandContext,
|
||||
args: &RunArgs,
|
||||
_cli_defaults: SettingsLayer,
|
||||
styles: &Styles,
|
||||
quiet: bool,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<CreatedRun> {
|
||||
let workflow_path = args
|
||||
.workflow
|
||||
|
|
@ -51,6 +47,7 @@ pub(crate) async fn create_run(
|
|||
})?;
|
||||
let client = ctx.server().await?;
|
||||
if !quiet {
|
||||
let printer = ctx.printer();
|
||||
let preflight = client.run_preflight(built.manifest.clone()).await?;
|
||||
let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics);
|
||||
if !diagnostics
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@
|
|||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::args::DiffArgs;
|
||||
|
|
@ -20,21 +17,16 @@ use crate::command_context::CommandContext;
|
|||
use crate::server_client::RunProjection;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: DiffArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn run(args: DiffArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
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?;
|
||||
|
||||
let patch = resolve_diff(&state, &args)?;
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
let value = serde_json::json!({
|
||||
"run_id": run_id,
|
||||
"node": args.node,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,19 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork};
|
||||
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: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
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?;
|
||||
|
|
@ -34,7 +26,7 @@ pub(crate) async fn run(
|
|||
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
|
||||
|
||||
if args.list {
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -56,7 +48,7 @@ pub(crate) async fn run(
|
|||
let run_id_string = run_id.to_string();
|
||||
let new_run_id_string = new_run_id.to_string();
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
let target = args.target.clone().unwrap_or_else(|| "latest".to_string());
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"source_run_id": run_id_string,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::redact::redact_jsonl_line;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tokio::time;
|
||||
|
|
@ -29,14 +26,8 @@ use crate::shared::format_usd_micros;
|
|||
|
||||
const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: &LogsArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(crate) async fn run(args: &LogsArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&args.run).await?.run_id;
|
||||
info!(run_id = %run_id, "Showing logs");
|
||||
|
|
@ -60,7 +51,7 @@ pub(crate) async fn run(
|
|||
let stdout = io::stdout();
|
||||
let is_tty = stdout.is_terminal();
|
||||
let mut out = stdout.lock();
|
||||
let pretty = args.pretty && cli.output.format != OutputFormat::Json;
|
||||
let pretty = args.pretty && !ctx.json_output();
|
||||
|
||||
for line in &filtered {
|
||||
if pretty {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_util::printer::Printer;
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::load_settings_with_storage_dir;
|
||||
|
||||
pub(crate) mod attach;
|
||||
pub(crate) mod command;
|
||||
|
|
@ -29,27 +25,18 @@ pub(crate) mod wait;
|
|||
|
||||
pub(crate) async fn dispatch(
|
||||
cmd: RunCommands,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
_process_local_json: bool,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
worker_token: Option<String>,
|
||||
) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
|
||||
match cmd {
|
||||
RunCommands::Run(args) => Box::pin(command::execute(args, cli, cli_layer, printer)).await,
|
||||
RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await,
|
||||
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_layer)?;
|
||||
let created_run = Box::pin(create::create_run(
|
||||
&ctx,
|
||||
&args,
|
||||
cli_defaults,
|
||||
styles,
|
||||
true,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let created_run = Box::pin(create::create_run(&ctx, &args, styles, true)).await?;
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?;
|
||||
} else {
|
||||
fabro_util::printout!(printer, "{}", created_run.run_id);
|
||||
|
|
@ -57,27 +44,28 @@ pub(crate) async fn dispatch(
|
|||
Ok(())
|
||||
}
|
||||
RunCommands::Start(StartArgs { server, run }) => {
|
||||
let ctx = CommandContext::for_target(&server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&server)?;
|
||||
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?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": run_id }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RunCommands::Attach(AttachArgs { server, run }) => {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let ctx = CommandContext::for_target(&server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&server)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&run).await?.run_id;
|
||||
let json = ctx.json_output();
|
||||
let exit_code = Box::pin(attach::attach_run_with_client(
|
||||
client.as_ref(),
|
||||
&run_id,
|
||||
false,
|
||||
styles,
|
||||
cli.output.format == OutputFormat::Json,
|
||||
ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose,
|
||||
json,
|
||||
ctx.verbose(),
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
|
|
@ -89,49 +77,50 @@ pub(crate) async fn dispatch(
|
|||
RunCommands::RunWorker(RunWorkerArgs {
|
||||
server,
|
||||
storage_dir,
|
||||
artifact_upload_token,
|
||||
run_dir,
|
||||
run_id,
|
||||
mode,
|
||||
}) => {
|
||||
runner::execute(
|
||||
let worker_token = worker_token
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow!("FABRO_WORKER_TOKEN is required for worker subprocess auth")
|
||||
})?;
|
||||
Box::pin(runner::execute(
|
||||
run_id,
|
||||
server,
|
||||
storage_dir,
|
||||
artifact_upload_token,
|
||||
run_dir,
|
||||
mode,
|
||||
)
|
||||
&worker_token,
|
||||
))
|
||||
.await
|
||||
}
|
||||
RunCommands::Diff(args) => diff::run(args, cli, cli_layer, printer).await,
|
||||
RunCommands::Diff(args) => diff::run(args, base_ctx).await,
|
||||
RunCommands::Logs(args) => {
|
||||
let styles = Styles::detect_stdout();
|
||||
logs::run(&args, &styles, cli, cli_layer, printer).await
|
||||
logs::run(&args, &styles, base_ctx).await
|
||||
}
|
||||
RunCommands::Resume(args) => {
|
||||
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_layer)?;
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep)
|
||||
};
|
||||
Box::pin(resume::resume_command(
|
||||
args, styles, cli, cli_layer, printer,
|
||||
))
|
||||
.await
|
||||
Box::pin(resume::resume_command(args, styles, base_ctx)).await
|
||||
}
|
||||
RunCommands::Rewind(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
Box::pin(rewind::run(&args, &styles, cli, cli_layer, printer)).await
|
||||
Box::pin(rewind::run(&args, &styles, base_ctx)).await
|
||||
}
|
||||
RunCommands::Fork(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
Box::pin(fork::run(&args, &styles, cli, cli_layer, printer)).await
|
||||
Box::pin(fork::run(&args, &styles, base_ctx)).await
|
||||
}
|
||||
RunCommands::Wait(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
wait::run(&args, &styles, cli, cli_layer, printer).await
|
||||
wait::run(&args, &styles, base_ctx).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer,
|
||||
RunSandboxLayer,
|
||||
};
|
||||
use fabro_types::settings::{ReplaceMap, SettingsLayer};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ pub(crate) fn run_args_layer(args: &RunArgs) -> Result<SettingsLayer> {
|
|||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
metadata: parse_labels(&args.label),
|
||||
metadata: ReplaceMap::from(parse_labels(&args.label)),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PreviewArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: PreviewArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&args.run).await?.run_id;
|
||||
let expires_in_secs =
|
||||
|
|
@ -31,7 +23,8 @@ pub(crate) async fn run(
|
|||
|
||||
info!(run_id = %args.run, port = args.port, "Generating preview URL");
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
let json = ctx.json_output();
|
||||
if json {
|
||||
match response.token {
|
||||
Some(token) => {
|
||||
print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?;
|
||||
|
|
@ -56,7 +49,7 @@ pub(crate) async fn run(
|
|||
}
|
||||
}
|
||||
|
||||
if args.open && !process_local_json {
|
||||
if should_open_browser(args.open, json) {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Preview URL opening is a fire-and-forget OS integration, not a Tokio-managed child process."
|
||||
|
|
@ -83,3 +76,23 @@ fn format_standard_output(url: &str, token: &str) -> String {
|
|||
fn format_signed_output(url: &str) -> String {
|
||||
format!("{url}\n")
|
||||
}
|
||||
|
||||
fn should_open_browser(open_requested: bool, json: bool) -> bool {
|
||||
open_requested && !json
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_open_browser;
|
||||
|
||||
#[test]
|
||||
fn json_output_suppresses_browser_opening() {
|
||||
assert!(!should_open_browser(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_output_honors_browser_opening() {
|
||||
assert!(should_open_browser(true, false));
|
||||
assert!(!should_open_browser(false, false));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::ResumeArgs;
|
||||
|
|
@ -15,17 +12,16 @@ use crate::shared::print_json_pretty;
|
|||
pub(crate) async fn resume_command(
|
||||
args: ResumeArgs,
|
||||
styles: &'static Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> anyhow::Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&args.run).await?.run_id;
|
||||
|
||||
super::start::start_run_with_client(client.as_ref(), &run_id, true).await?;
|
||||
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let json = ctx.json_output();
|
||||
if args.detach {
|
||||
if json {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": run_id }))?;
|
||||
|
|
@ -39,7 +35,7 @@ pub(crate) async fn resume_command(
|
|||
true,
|
||||
styles,
|
||||
json,
|
||||
ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose,
|
||||
ctx.verbose(),
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ 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::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_types::{EventBody, RunEvent};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
|
@ -17,7 +15,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 +31,11 @@ pub(crate) struct TimelineEntryJson {
|
|||
pub(crate) async fn run(
|
||||
args: &RewindArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let repo = Repository::discover(".").context("not in a git repository")?;
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
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?;
|
||||
|
|
@ -54,7 +51,7 @@ pub(crate) async fn run(
|
|||
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
|
||||
|
||||
if args.list || args.target.is_none() {
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&timeline_entries_json(&timeline))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -79,7 +76,7 @@ pub(crate) async fn run(
|
|||
|
||||
let run_id_string = run_id.to_string();
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"run_id": run_id_string,
|
||||
"target": target_arg,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMe
|
|||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::settings::{InterpString, SettingsLayer};
|
||||
use fabro_types::{ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId};
|
||||
use fabro_types::{ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId};
|
||||
use fabro_vault::Vault;
|
||||
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
|
||||
use fabro_workflow::event::{Emitter, RunEventSink};
|
||||
|
|
@ -57,14 +57,15 @@ pub(crate) async fn execute(
|
|||
run_id: RunId,
|
||||
server: String,
|
||||
storage_dir: Option<PathBuf>,
|
||||
artifact_upload_token: Option<String>,
|
||||
run_dir: PathBuf,
|
||||
mode: RunWorkerMode,
|
||||
worker_token: &str,
|
||||
) -> Result<()> {
|
||||
let _ = fabro_proc::title_init();
|
||||
set_worker_title(&run_id, initial_worker_title_phase(mode));
|
||||
|
||||
let client = server_client::connect_server_target_direct(&server).await?;
|
||||
let target = server.parse::<fabro_client::ServerTarget>()?;
|
||||
let client = server_client::connect_server_target_with_bearer(&target, worker_token).await?;
|
||||
let run_store = HttpRunStore::connect(run_id, client.clone_for_reuse()).await?;
|
||||
let run_state = run_store
|
||||
.state()
|
||||
|
|
@ -77,7 +78,7 @@ pub(crate) async fn execute(
|
|||
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
|
||||
run_id,
|
||||
client.clone_for_reuse(),
|
||||
artifact_upload_token,
|
||||
worker_token.to_owned(),
|
||||
)));
|
||||
let interviewer = Arc::new(ControlInterviewer::new());
|
||||
let cancel_token = Arc::new(AtomicBool::new(false));
|
||||
|
|
@ -98,13 +99,16 @@ pub(crate) async fn execute(
|
|||
emitter: Arc::new(Emitter::new(run_id)),
|
||||
interviewer,
|
||||
run_store: run_store.clone(),
|
||||
event_sink: RunEventSink::fanout(vec![
|
||||
RunEventSink::backend(run_store),
|
||||
RunEventSink::callback(move |event| {
|
||||
update_worker_title_from_event(&event);
|
||||
async move { Ok(()) }
|
||||
}),
|
||||
]),
|
||||
event_sink: RunEventSink::map(
|
||||
stamp_system_worker,
|
||||
RunEventSink::fanout(vec![
|
||||
RunEventSink::backend(run_store),
|
||||
RunEventSink::callback(move |event| {
|
||||
update_worker_title_from_event(&event);
|
||||
async move { Ok(()) }
|
||||
}),
|
||||
]),
|
||||
),
|
||||
artifact_sink,
|
||||
run_control: Some(run_control),
|
||||
github_app,
|
||||
|
|
@ -241,22 +245,19 @@ async fn apply_worker_control_line(
|
|||
fn build_artifact_uploader(
|
||||
run_id: RunId,
|
||||
client: server_client::Client,
|
||||
artifact_upload_token: Option<String>,
|
||||
worker_token: String,
|
||||
) -> Arc<dyn StageArtifactUploader> {
|
||||
match artifact_upload_token {
|
||||
Some(token) => Arc::new(HttpArtifactUploader {
|
||||
run_id,
|
||||
client,
|
||||
bearer_token: token,
|
||||
}),
|
||||
None => Arc::new(MissingArtifactUploadTokenUploader { run_id }),
|
||||
}
|
||||
Arc::new(HttpArtifactUploader {
|
||||
run_id,
|
||||
client,
|
||||
worker_token,
|
||||
})
|
||||
}
|
||||
|
||||
struct HttpArtifactUploader {
|
||||
run_id: RunId,
|
||||
client: server_client::Client,
|
||||
bearer_token: String,
|
||||
worker_token: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -280,7 +281,7 @@ impl StageArtifactUploader for HttpArtifactUploader {
|
|||
stage_id,
|
||||
&artifact.path,
|
||||
&artifact_capture_dir.join(&artifact.path),
|
||||
&self.bearer_token,
|
||||
&self.worker_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -291,31 +292,12 @@ impl StageArtifactUploader for HttpArtifactUploader {
|
|||
stage_id,
|
||||
artifact_capture_dir,
|
||||
artifacts,
|
||||
&self.bearer_token,
|
||||
&self.worker_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct MissingArtifactUploadTokenUploader {
|
||||
run_id: RunId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StageArtifactUploader for MissingArtifactUploadTokenUploader {
|
||||
async fn upload_stage_artifacts(
|
||||
&self,
|
||||
_stage_id: &fabro_types::StageId,
|
||||
_artifact_capture_dir: &Path,
|
||||
_artifacts: &[ArtifactUpload],
|
||||
) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"run {} could not upload artifacts because the worker did not receive an artifact upload token",
|
||||
self.run_id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpRunStore {
|
||||
run_id: RunId,
|
||||
|
|
@ -502,6 +484,13 @@ fn update_worker_title_from_event(event: &RunEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
|
||||
if event.actor.is_none() {
|
||||
event.actor = Some(ActorRef::system_worker());
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
fn maybe_build_github_credentials(
|
||||
settings: &SettingsLayer,
|
||||
vault: Option<&fabro_vault::Vault>,
|
||||
|
|
@ -587,6 +576,7 @@ mod tests {
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_auth::{AuthCredential, AuthDetails};
|
||||
use fabro_config::Storage;
|
||||
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
||||
|
|
@ -595,18 +585,36 @@ mod tests {
|
|||
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
|
||||
RunFailedProps, RunStatusTransitionProps,
|
||||
};
|
||||
use fabro_types::{EventBody, FailureReason, SuccessReason, fixtures};
|
||||
use fabro_types::{ActorRef, EventBody, FailureReason, SuccessReason, fixtures};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use fabro_workflow::artifact_upload::StageArtifactUploader;
|
||||
use fabro_workflow::event::RunEventSink;
|
||||
|
||||
use super::{
|
||||
MissingArtifactUploadTokenUploader, WorkerControlStreamEvent, WorkerTitlePhase,
|
||||
apply_worker_control_line, handle_worker_control_stream_events, initial_worker_title_phase,
|
||||
load_worker_vault, read_worker_control_stream_blocking, worker_title,
|
||||
WorkerControlStreamEvent, WorkerTitlePhase, apply_worker_control_line,
|
||||
handle_worker_control_stream_events, initial_worker_title_phase, load_worker_vault,
|
||||
read_worker_control_stream_blocking, stamp_system_worker, worker_title,
|
||||
worker_title_phase_for_event,
|
||||
};
|
||||
use crate::args::RunWorkerMode;
|
||||
|
||||
fn running_event(actor: Option<ActorRef>) -> fabro_types::RunEvent {
|
||||
fabro_types::RunEvent {
|
||||
id: "evt_1".to_string(),
|
||||
ts: Utc::now(),
|
||||
run_id: fixtures::RUN_1,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor,
|
||||
body: EventBody::RunRunning(RunStatusTransitionProps::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_title_uses_short_run_id_and_phase() {
|
||||
let short_id: String = fixtures::RUN_1.to_string().chars().take(12).collect();
|
||||
|
|
@ -699,24 +707,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_system_worker_fills_missing_actor_only() {
|
||||
let stamped = stamp_system_worker(running_event(None));
|
||||
|
||||
assert_eq!(stamped.actor, Some(ActorRef::system_worker()));
|
||||
|
||||
let existing_actor = ActorRef::user("octocat".to_string());
|
||||
let stamped = stamp_system_worker(running_event(Some(existing_actor.clone())));
|
||||
assert_eq!(stamped.actor, Some(existing_actor));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_artifact_upload_token_error_does_not_mention_removed_storage_mode() {
|
||||
let uploader = MissingArtifactUploadTokenUploader {
|
||||
run_id: fixtures::RUN_1,
|
||||
};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
let error = uploader
|
||||
.upload_stage_artifacts(&fabro_types::StageId::new("code", 2), temp.path(), &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("worker did not receive an artifact upload token")
|
||||
async fn worker_event_stamp_applies_to_all_fanout_sinks() {
|
||||
let first = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let second = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let first_events = Arc::clone(&first);
|
||||
let second_events = Arc::clone(&second);
|
||||
let sink = RunEventSink::map(
|
||||
stamp_system_worker,
|
||||
RunEventSink::fanout(vec![
|
||||
RunEventSink::callback(move |event| {
|
||||
let first_events = Arc::clone(&first_events);
|
||||
async move {
|
||||
first_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
RunEventSink::callback(move |event| {
|
||||
let second_events = Arc::clone(&second_events);
|
||||
async move {
|
||||
second_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert!(!error.to_string().contains("object-backed artifacts"));
|
||||
let event = running_event(None);
|
||||
|
||||
sink.write_run_event(&event).await.unwrap();
|
||||
|
||||
let first = first.lock().await;
|
||||
let second = second.lock().await;
|
||||
assert_eq!(first[0].actor, Some(ActorRef::system_worker()));
|
||||
assert_eq!(second[0].actor, Some(ActorRef::system_worker()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
use anyhow::{Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{SshArgs, require_no_json_override};
|
||||
use crate::args::SshArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: SshArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
if process_local_json && !args.print {
|
||||
require_no_json_override(process_local_json)?;
|
||||
pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
if !args.print {
|
||||
base_ctx.require_no_json_override()?;
|
||||
}
|
||||
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
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?;
|
||||
|
|
@ -27,7 +19,7 @@ pub(crate) async fn run(
|
|||
info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access");
|
||||
|
||||
if args.print {
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "command": ssh.command }))?;
|
||||
} else {
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ use std::io::Write;
|
|||
|
||||
use anyhow::{Result, bail};
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::records::Conclusion;
|
||||
|
|
@ -24,14 +22,9 @@ use crate::args::WaitArgs;
|
|||
use crate::command_context::CommandContext;
|
||||
use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind};
|
||||
|
||||
pub(crate) async fn run(
|
||||
args: &WaitArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
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");
|
||||
|
|
@ -63,7 +56,7 @@ pub(crate) async fn run(
|
|||
|
||||
let conclusion = client.get_run_state(&run_id).await?.conclusion;
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
let json_value = build_json_output(final_status, &run_id, conclusion.as_ref());
|
||||
let mut out = std::io::stdout().lock();
|
||||
serde_json::to_writer_pretty(&mut out, &json_value)?;
|
||||
|
|
|
|||
|
|
@ -1,46 +1,24 @@
|
|||
use anyhow::{Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use super::short_run_id;
|
||||
use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn archive_command(
|
||||
args: &RunsArchiveArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
run_bulk(
|
||||
Action::Archive,
|
||||
&args.runs,
|
||||
ctx.server().await?.as_ref(),
|
||||
cli,
|
||||
printer,
|
||||
)
|
||||
.await
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
run_bulk(Action::Archive, &args.runs, &ctx).await
|
||||
}
|
||||
|
||||
pub(crate) async fn unarchive_command(
|
||||
args: &RunsUnarchiveArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
run_bulk(
|
||||
Action::Unarchive,
|
||||
&args.runs,
|
||||
ctx.server().await?.as_ref(),
|
||||
cli,
|
||||
printer,
|
||||
)
|
||||
.await
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
run_bulk(Action::Unarchive, &args.runs, &ctx).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
|
@ -62,14 +40,11 @@ impl Action {
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_bulk(
|
||||
action: Action,
|
||||
identifiers: &[String],
|
||||
client: &server_client::Client,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext) -> Result<()> {
|
||||
let client = ctx.server().await?;
|
||||
let client = client.as_ref();
|
||||
let json = ctx.json_output();
|
||||
let printer = ctx.printer();
|
||||
let mut had_errors = false;
|
||||
let mut changed = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -20,8 +18,9 @@ pub(crate) struct InspectOutput {
|
|||
pub sandbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run(args: &InspectArgs, cli_layer: &CliLayer, printer: Printer) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
pub(crate) async fn run(args: &InspectArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
let client = ctx.server().await?;
|
||||
let run = ServerRunSummaryInfo::from_summary(client.resolve_run(&args.run).await?);
|
||||
let run_id = run.run_id();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ use anyhow::Result;
|
|||
use chrono::Utc;
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{Cell, CellStruct, Color, Style, Table};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_util::text::strip_goal_decoration;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
|
@ -20,11 +17,10 @@ use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path};
|
|||
pub(crate) async fn list_command(
|
||||
args: &RunsListArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
let printer = ctx.printer();
|
||||
let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let filtered = filter_server_runs(
|
||||
|
|
@ -35,7 +31,7 @@ pub(crate) async fn list_command(
|
|||
!args.all,
|
||||
);
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
let json_rows: Vec<_> = filtered
|
||||
.iter()
|
||||
.map(|run| {
|
||||
|
|
|
|||
|
|
@ -1,35 +1,24 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::RunsCommands;
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod inspect;
|
||||
pub(crate) mod list;
|
||||
pub(crate) mod rm;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
cmd: RunsCommands,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Result<()> {
|
||||
match cmd {
|
||||
RunsCommands::Ps(args) => {
|
||||
let styles = Styles::detect_stdout();
|
||||
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_layer, printer).await,
|
||||
RunsCommands::Archive(args) => {
|
||||
archive::archive_command(&args, cli, cli_layer, printer).await
|
||||
}
|
||||
RunsCommands::Unarchive(args) => {
|
||||
archive::unarchive_command(&args, cli, cli_layer, printer).await
|
||||
list::list_command(&args, &styles, base_ctx).await
|
||||
}
|
||||
RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await,
|
||||
RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await,
|
||||
RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await,
|
||||
RunsCommands::Unarchive(args) => archive::unarchive_command(&args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
use anyhow::{Result, bail};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use super::short_run_id;
|
||||
use crate::args::RunsRemoveArgs;
|
||||
|
|
@ -9,23 +6,16 @@ use crate::command_context::CommandContext;
|
|||
use crate::server_client;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn remove_command(
|
||||
args: &RunsRemoveArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?;
|
||||
remove_from(args, ctx.server().await?.as_ref(), cli, printer).await
|
||||
pub(crate) async fn remove_command(args: &RunsRemoveArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&args.server)?;
|
||||
remove_from(args, &ctx).await
|
||||
}
|
||||
|
||||
async fn remove_from(
|
||||
args: &RunsRemoveArgs,
|
||||
client: &server_client::Client,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
async fn remove_from(args: &RunsRemoveArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let client = ctx.server().await?;
|
||||
let client = client.as_ref();
|
||||
let json = ctx.json_output();
|
||||
let printer = ctx.printer();
|
||||
let mut had_errors = false;
|
||||
let mut removed = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
|
|
|||
|
|
@ -1,24 +1,12 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::SandboxCommand;
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
command: SandboxCommand,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
process_local_json: bool,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(command: SandboxCommand, base_ctx: &CommandContext) -> Result<()> {
|
||||
match command {
|
||||
SandboxCommand::Cp(args) => super::run::cp::cp_command(args, cli, cli_layer, printer).await,
|
||||
SandboxCommand::Preview(args) => {
|
||||
super::run::preview::run(args, cli, cli_layer, process_local_json, printer).await
|
||||
}
|
||||
SandboxCommand::Ssh(args) => {
|
||||
super::run::ssh::run(args, cli, cli_layer, process_local_json, printer).await
|
||||
}
|
||||
SandboxCommand::Cp(args) => super::run::cp::cp_command(args, base_ctx).await,
|
||||
SandboxCommand::Preview(args) => super::run::preview::run(args, base_ctx).await,
|
||||
SandboxCommand::Ssh(args) => super::run::ssh::run(args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ use anyhow::Result;
|
|||
use chrono::{DateTime, Utc};
|
||||
use cli_table::format::{Border, Separator};
|
||||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::SecretListArgs;
|
||||
use crate::server_client::Client;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
fn format_age(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
|
|
@ -22,14 +19,11 @@ fn format_age(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_command(
|
||||
client: &Client,
|
||||
_args: &SecretListArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) async fn list_command(_args: &SecretListArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let client = ctx.server().await?;
|
||||
let printer = ctx.printer();
|
||||
let secrets = client.list_secrets().await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&secrets)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,24 +3,15 @@ mod rm;
|
|||
mod set;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{SecretCommand, SecretNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: SecretNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&ns.target, printer, cli_layer)?;
|
||||
let server = ctx.server().await?;
|
||||
pub(crate) async fn dispatch(ns: SecretNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_target(&ns.target)?;
|
||||
match ns.command {
|
||||
SecretCommand::List(args) => list::list_command(&server, &args, cli, printer).await,
|
||||
SecretCommand::Rm(args) => rm::rm_command(&server, &args, cli, printer).await,
|
||||
SecretCommand::Set(args) => set::set_command(&server, &args, cli, printer).await,
|
||||
SecretCommand::List(args) => list::list_command(&args, &ctx).await,
|
||||
SecretCommand::Rm(args) => rm::rm_command(&args, &ctx).await,
|
||||
SecretCommand::Set(args) => set::set_command(&args, &ctx).await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,16 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::SecretRmArgs;
|
||||
use crate::server_client::Client;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(super) async fn rm_command(
|
||||
client: &Client,
|
||||
args: &SecretRmArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) async fn rm_command(args: &SecretRmArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let client = ctx.server().await?;
|
||||
client.delete_secret_by_name(&args.key).await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "key": args.key }))?;
|
||||
} else {
|
||||
fabro_util::printerr!(printer, "Removed {}", args.key);
|
||||
fabro_util::printerr!(ctx.printer(), "Removed {}", args.key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,10 @@ use std::io::{IsTerminal, Read as _};
|
|||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use fabro_api::types;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::args::{SecretSetArgs, SecretTypeArg};
|
||||
use crate::server_client::Client;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::shared::provider_auth::prompt_password;
|
||||
|
||||
|
|
@ -57,13 +54,9 @@ async fn resolve_value(args: &SecretSetArgs) -> Result<String> {
|
|||
bail!("secret value required: pass <VALUE>, use --value-stdin, or run interactively")
|
||||
}
|
||||
|
||||
pub(super) async fn set_command(
|
||||
client: &Client,
|
||||
args: &SecretSetArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) async fn set_command(args: &SecretSetArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let value = resolve_value(args).await?;
|
||||
let client = ctx.server().await?;
|
||||
let meta = client
|
||||
.create_secret(types::CreateSecretRequest {
|
||||
name: args.key.clone(),
|
||||
|
|
@ -72,10 +65,10 @@ pub(super) async fn set_command(
|
|||
description: args.description.clone(),
|
||||
})
|
||||
.await?;
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&meta)?;
|
||||
} else {
|
||||
fabro_util::printerr!(printer, "Set {}", meta.name);
|
||||
fabro_util::printerr!(ctx.printer(), "Set {}", meta.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ use std::path::{Path, PathBuf};
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use fabro_config::RuntimeDirectory;
|
||||
use fabro_config::bind::{Bind, BindRequest};
|
||||
use fabro_config::daemon::ServerDaemon;
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config};
|
||||
use fabro_config::{RuntimeDirectory, envfile};
|
||||
use fabro_server::jwt_auth::auth_method_name;
|
||||
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs};
|
||||
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start};
|
||||
use fabro_server::{process_env_snapshot, validate_startup};
|
||||
use fabro_types::settings::ServerAuthMethod;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::session_secret;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tokio::net::{TcpStream, UnixStream};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
|
@ -222,33 +222,6 @@ fn configured_auth_methods(config_path: Option<&Path>) -> Vec<ServerAuthMethod>
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn valid_session_secret(secret: &str) -> bool {
|
||||
session_secret::validate_session_secret(secret).is_ok()
|
||||
}
|
||||
|
||||
fn load_or_create_local_session_secret(runtime_directory: &RuntimeDirectory) -> Result<String> {
|
||||
if let Some(secret) = std::env::var("SESSION_SECRET")
|
||||
.ok()
|
||||
.filter(|secret| valid_session_secret(secret))
|
||||
{
|
||||
return Ok(secret);
|
||||
}
|
||||
|
||||
let server_env_path = runtime_directory.env_path();
|
||||
if let Some(secret) = envfile::read_env_file(&server_env_path)
|
||||
.ok()
|
||||
.and_then(|entries| entries.get("SESSION_SECRET").cloned())
|
||||
.filter(|secret| valid_session_secret(secret))
|
||||
{
|
||||
return Ok(secret);
|
||||
}
|
||||
|
||||
let secret = session_secret::generate_session_secret();
|
||||
envfile::merge_env_file(&server_env_path, [("SESSION_SECRET", secret.as_str())])
|
||||
.with_context(|| format!("merging session secret into {}", server_env_path.display()))?;
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foreground mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -261,18 +234,6 @@ async fn execute_foreground(
|
|||
styles: &'static Styles,
|
||||
_printer: Printer,
|
||||
) -> Result<()> {
|
||||
let session_secret = load_or_create_local_session_secret(&RuntimeDirectory::new(&storage_dir))?;
|
||||
let prior_session_secret = std::env::var_os("SESSION_SECRET");
|
||||
std::env::set_var("SESSION_SECRET", &session_secret);
|
||||
let _env_guard =
|
||||
scopeguard::guard(
|
||||
prior_session_secret,
|
||||
|prior_session_secret| match prior_session_secret {
|
||||
Some(value) => std::env::set_var("SESSION_SECRET", value),
|
||||
None => std::env::remove_var("SESSION_SECRET"),
|
||||
},
|
||||
);
|
||||
|
||||
super::foreground::serve_with_daemon_record(serve_args, bind, storage_dir, styles).await
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +264,13 @@ async fn execute_daemon(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let resolved_settings = resolve_runtime_server_settings_for_start(serve_args, storage_dir)?;
|
||||
validate_startup(
|
||||
runtime_directory.env_path().as_path(),
|
||||
process_env_snapshot(),
|
||||
&resolved_settings,
|
||||
)?;
|
||||
|
||||
let log_path = runtime_directory.log_path();
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
|
|
@ -347,9 +315,7 @@ async fn execute_daemon(
|
|||
cmd.arg("--watch-web");
|
||||
}
|
||||
|
||||
let session_secret = load_or_create_local_session_secret(&runtime_directory)?;
|
||||
cmd.arg("--storage-dir").arg(storage_dir);
|
||||
cmd.env("SESSION_SECRET", &session_secret);
|
||||
|
||||
cmd.env_remove("FABRO_JSON");
|
||||
cmd.stdout(stdout_log)
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
pub(crate) mod dump;
|
||||
pub(crate) mod rebuild;
|
||||
mod run_export;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{StoreCommand, StoreNamespace};
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: StoreNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
match ns.command {
|
||||
StoreCommand::Dump(args) => dump::dump_command(&args, cli, cli_layer, printer).await,
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub(super) use fabro_workflow::run_dump::RunDump as StoreRunExport;
|
||||
|
|
@ -3,23 +3,15 @@ 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::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::DfArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::{format_size, print_json_pretty};
|
||||
|
||||
pub(super) async fn df_command(
|
||||
args: &DfArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?;
|
||||
pub(super) async fn df_command(args: &DfArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_connection(&args.connection)?;
|
||||
let server = ctx.server().await?;
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let json = ctx.json_output();
|
||||
|
||||
let (output, storage_dir) = if json {
|
||||
(server.get_system_disk_usage(args.verbose).await?, None)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
use anyhow::Result;
|
||||
use fabro_client::sse;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use futures::StreamExt;
|
||||
|
||||
use crate::args::SystemEventsArgs;
|
||||
|
|
@ -10,16 +7,14 @@ use crate::command_context::CommandContext;
|
|||
|
||||
pub(super) async fn events_command(
|
||||
args: &SystemEventsArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?;
|
||||
let ctx = base_ctx.with_connection(&args.connection)?;
|
||||
let server = ctx.server().await?;
|
||||
let mut stream = server.attach_events(&args.run_ids).await?;
|
||||
let mut pending = Vec::new();
|
||||
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let json = ctx.json_output();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
pending.extend_from_slice(&chunk);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::SystemInfoArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(super) async fn info_command(
|
||||
args: &SystemInfoArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?;
|
||||
pub(super) async fn info_command(args: &SystemInfoArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_connection(&args.connection)?;
|
||||
let server = ctx.server().await?;
|
||||
let response = server.get_system_info().await?;
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&response)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,16 @@ mod info;
|
|||
mod prune;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
pub(crate) use prune::parse_duration;
|
||||
|
||||
use crate::args::{SystemCommand, SystemNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
ns: SystemNamespace,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn dispatch(ns: SystemNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
SystemCommand::Info(args) => info::info_command(&args, cli, cli_layer, printer).await,
|
||||
SystemCommand::Prune(args) => prune::prune_command(&args, cli, cli_layer, printer).await,
|
||||
SystemCommand::Df(args) => df::df_command(&args, cli, cli_layer, printer).await,
|
||||
SystemCommand::Events(args) => events::events_command(&args, cli, cli_layer, printer).await,
|
||||
SystemCommand::Info(args) => info::info_command(&args, base_ctx).await,
|
||||
SystemCommand::Prune(args) => prune::prune_command(&args, base_ctx).await,
|
||||
SystemCommand::Df(args) => df::df_command(&args, base_ctx).await,
|
||||
SystemCommand::Events(args) => events::events_command(&args, base_ctx).await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ use std::collections::HashMap;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_api::types;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use tracing::{debug, info};
|
||||
|
||||
|
|
@ -11,13 +9,9 @@ use crate::args::RunsPruneArgs;
|
|||
use crate::command_context::CommandContext;
|
||||
use crate::shared::{format_size, print_json_pretty};
|
||||
|
||||
pub(super) async fn prune_command(
|
||||
args: &RunsPruneArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?;
|
||||
pub(super) async fn prune_command(args: &RunsPruneArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let ctx = base_ctx.with_connection(&args.connection)?;
|
||||
let printer = ctx.printer();
|
||||
let server = ctx.server().await?;
|
||||
let response = server
|
||||
.prune_runs(types::PruneRunsRequest {
|
||||
|
|
@ -29,7 +23,7 @@ pub(super) async fn prune_command(
|
|||
workflow: args.filter.workflow.clone(),
|
||||
})
|
||||
.await?;
|
||||
prune_from(&response, cli.output.format == OutputFormat::Json, printer)
|
||||
prune_from(&response, ctx.json_output(), printer)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
||||
|
|
|
|||
|
|
@ -15,14 +15,13 @@ use std::time::Duration;
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_config::Storage;
|
||||
use fabro_config::daemon::ServerDaemon;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::Home;
|
||||
use fabro_util::printer::Printer;
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::args::UninstallArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::server::stop;
|
||||
use crate::shared::{format_size, print_json_pretty, tilde_path};
|
||||
use crate::{local_server, user_config};
|
||||
|
|
@ -43,12 +42,9 @@ struct Inventory {
|
|||
clippy::unused_async,
|
||||
reason = "The shared command dispatch path expects an async handler."
|
||||
)]
|
||||
pub(crate) async fn run_uninstall(
|
||||
args: &UninstallArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let json = ctx.json_output();
|
||||
let printer = ctx.printer();
|
||||
let home = Home::from_env();
|
||||
let home_root = home.root().to_path_buf();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use tokio::task::JoinHandle;
|
|||
use tracing::debug;
|
||||
|
||||
use crate::args::UpgradeArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
// ── Download backend abstraction ───────────────────────────────────────────
|
||||
|
|
@ -434,11 +435,9 @@ impl UpgradeCheckState {
|
|||
|
||||
// ── Main upgrade command ───────────────────────────────────────────────────
|
||||
|
||||
pub(crate) async fn run_upgrade(
|
||||
args: UpgradeArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Result<()> {
|
||||
let cli = &ctx.user_settings().cli;
|
||||
let printer = ctx.printer();
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("resolving current fabro executable path")?
|
||||
.canonicalize()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,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::{CliNamespace, SettingsLayer};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::ValidateArgs;
|
||||
|
|
@ -15,11 +13,10 @@ use crate::shared::{print_diagnostics, print_json_pretty, relative_path};
|
|||
pub(crate) async fn run(
|
||||
args: &ValidateArgs,
|
||||
styles: &Styles,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
base_ctx: &CommandContext,
|
||||
) -> anyhow::Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
|
|
@ -33,7 +30,7 @@ pub(crate) async fn run(
|
|||
let response = client.run_preflight(built.manifest).await?;
|
||||
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"workflow_name": response.workflow.name,
|
||||
"nodes": response.workflow.nodes,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
use std::io::IsTerminal;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
|
|
@ -16,14 +14,10 @@ use crate::command_context::CommandContext;
|
|||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::{self, ServerTarget};
|
||||
|
||||
pub(crate) async fn version_command(
|
||||
args: &VersionArgs,
|
||||
cli: &CliNamespace,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let client = client_info();
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?;
|
||||
let printer = base_ctx.printer();
|
||||
let ctx = base_ctx.with_target(&args.target)?;
|
||||
let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?;
|
||||
let server_address = format_server_target(&server_target);
|
||||
let server_info = match ctx.server().await {
|
||||
|
|
@ -49,7 +43,7 @@ pub(crate) async fn version_command(
|
|||
},
|
||||
};
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&json_output(&client, &server_info))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,13 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_config::project::{discover_project_config, resolve_fabro_root};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::WorkflowCreateArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::{print_json_pretty, relative_path};
|
||||
|
||||
pub(super) fn create_command(
|
||||
args: &WorkflowCreateArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
let Some((config_path, config)) = discover_project_config(&cwd)? else {
|
||||
|
|
@ -31,7 +26,7 @@ pub(super) fn create_command(
|
|||
let fabro_root = resolve_fabro_root(&config_path, &config);
|
||||
let created = write_workflow_scaffold(args, &fabro_root)?;
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
let created: Vec<_> = created.iter().map(|path| relative_path(path)).collect();
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"name": args.name,
|
||||
|
|
|
|||
|
|
@ -5,21 +5,17 @@ use fabro_config::project::{
|
|||
WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed,
|
||||
resolve_fabro_root,
|
||||
};
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_types::settings::cli::OutputFormat;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::WorkflowListArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::{color_if, print_json_pretty, relative_path};
|
||||
|
||||
const GOAL_MAX_LEN: usize = 60;
|
||||
|
||||
pub(super) fn list_command(
|
||||
_args: &WorkflowListArgs,
|
||||
cli: &CliNamespace,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
let styles = Styles::detect_stderr();
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
|
|
@ -36,7 +32,7 @@ pub(super) fn list_command(
|
|||
|
||||
let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref());
|
||||
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
if base_ctx.json_output() {
|
||||
print_json_pretty(&workflows)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ mod create;
|
|||
mod list;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_types::settings::CliNamespace;
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use crate::args::{WorkflowCommand, WorkflowNamespace};
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
pub(crate) fn dispatch(ns: WorkflowNamespace, cli: &CliNamespace, printer: Printer) -> Result<()> {
|
||||
pub(crate) fn dispatch(ns: WorkflowNamespace, base_ctx: &CommandContext) -> Result<()> {
|
||||
match ns.command {
|
||||
WorkflowCommand::List(args) => list::list_command(&args, cli, printer),
|
||||
WorkflowCommand::Create(args) => create::create_command(&args, cli, printer),
|
||||
WorkflowCommand::List(args) => list::list_command(&args, base_ctx),
|
||||
WorkflowCommand::Create(args) => create::create_command(&args, base_ctx),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,16 @@ use fabro_server::serve::resolve_bind_request_from_settings;
|
|||
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};
|
||||
|
||||
pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result<PathBuf> {
|
||||
storage_dir_with_lookup(settings, &|name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
pub(crate) fn storage_dir_with_lookup(
|
||||
settings: &SettingsLayer,
|
||||
lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<PathBuf> {
|
||||
let storage_root = fabro_config::resolve_storage_root(settings);
|
||||
let resolved_root = storage_root
|
||||
.resolve(|name| std::env::var(name).ok())
|
||||
.resolve(lookup)
|
||||
.map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?;
|
||||
Ok(PathBuf::from(resolved_root.value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,10 @@ use std::ffi::OsString;
|
|||
use anyhow::Result;
|
||||
use args::{
|
||||
Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace,
|
||||
global_args_cli_layer, printer_from_verbosity, require_no_json_override,
|
||||
global_args_cli_layer, require_no_json_override,
|
||||
};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use fabro_config::merge::combine_files;
|
||||
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
use fabro_util::exit::ExitClass;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
|
@ -38,6 +35,8 @@ use fabro_util::{browser, exit};
|
|||
use rustls::crypto::ring::default_provider;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::command_context::CommandContext;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fabro", version, long_version = LONG_VERSION)]
|
||||
struct Cli {
|
||||
|
|
@ -73,12 +72,33 @@ async fn main() {
|
|||
std::process::exit(commands::render_graph::execute());
|
||||
}
|
||||
|
||||
// Capture the worker bearer token immediately and scrub it from the process
|
||||
// env before any subprocess can be spawned. Every descendant of the worker
|
||||
// (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore
|
||||
// inherits a process env that no longer contains FABRO_WORKER_TOKEN, so an
|
||||
// unscrubbed spawn site cannot leak it. The token flows to `runner::execute`
|
||||
// through an explicit function argument instead of the environment.
|
||||
let worker_token = if subcommand == Some("__run-worker") {
|
||||
let token = std::env::var("FABRO_WORKER_TOKEN").ok();
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Scrub the worker bearer from this process's env before any \
|
||||
child process is spawned, so no descendant can inherit it."
|
||||
)]
|
||||
{
|
||||
std::env::remove_var("FABRO_WORKER_TOKEN");
|
||||
}
|
||||
token
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tel_panic::install_panic_hook();
|
||||
fabro_telemetry::init_cli();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let (command_name, result) = Box::pin(main_inner()).await;
|
||||
let (command_name, result) = Box::pin(main_inner(worker_token)).await;
|
||||
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
let exit_code = result.as_ref().err().map_or(0, exit::exit_code_for);
|
||||
|
||||
|
|
@ -146,7 +166,7 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
async fn main_inner() -> (String, Result<()>) {
|
||||
async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
|
||||
let _ = default_provider().install_default();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -165,22 +185,14 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
Err(err) => return (command_name, Err(err)),
|
||||
};
|
||||
|
||||
let user_settings = match user_config::load_settings() {
|
||||
Ok(settings) => settings,
|
||||
let base_ctx = match CommandContext::from_disk(&cli_layer, process_local_json) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => return (command_name, Err(err)),
|
||||
};
|
||||
let combined_settings = combine_files(user_settings, SettingsLayer {
|
||||
cli: Some(cli_layer.clone()),
|
||||
..SettingsLayer::default()
|
||||
});
|
||||
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);
|
||||
let printer = base_ctx.printer();
|
||||
|
||||
let config_log_level = match &pre_tracing_bootstrap.sink {
|
||||
logging::InternalLogSink::Cli => cli_settings.logging.level.clone(),
|
||||
logging::InternalLogSink::Cli => base_ctx.user_settings().cli.logging.level.clone(),
|
||||
logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(),
|
||||
};
|
||||
if let Err(err) = logging::init_tracing(
|
||||
|
|
@ -204,57 +216,44 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
| Commands::Repo(_)
|
||||
| Commands::Install { .. }
|
||||
) {
|
||||
commands::upgrade::spawn_upgrade_check(cli_settings.updates.check, printer)
|
||||
commands::upgrade::spawn_upgrade_check(base_ctx.user_settings().cli.updates.check, printer)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = Box::pin(async move {
|
||||
match *command {
|
||||
Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?,
|
||||
Commands::Exec(args) => {
|
||||
commands::exec::execute(args, &base_ctx).await?;
|
||||
}
|
||||
Commands::RunCmd(cmd) => {
|
||||
Box::pin(commands::run::dispatch(
|
||||
cmd,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
process_local_json,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_token)).await?;
|
||||
}
|
||||
Commands::Preflight(args) => {
|
||||
commands::preflight::execute(args, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::preflight::execute(args, &base_ctx).await?;
|
||||
}
|
||||
Commands::Validate(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
commands::validate::run(&args, &styles, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::validate::run(&args, &styles, &base_ctx).await?;
|
||||
}
|
||||
Commands::Graph(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
commands::graph::run(
|
||||
&args,
|
||||
&styles,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
process_local_json,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
commands::graph::run(&args, &styles, &base_ctx).await?;
|
||||
}
|
||||
Commands::Parse(args) => {
|
||||
commands::parse::run(&args, &cli_settings, printer)?;
|
||||
commands::parse::run(&args)?;
|
||||
}
|
||||
Commands::Artifact(ns) => {
|
||||
commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::artifact::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Store(ns) => {
|
||||
commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
||||
Commands::Dump(args) => {
|
||||
commands::dump::run(&args, &base_ctx).await?;
|
||||
}
|
||||
Commands::RunsCmd(cmd) => {
|
||||
commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::runs::dispatch(cmd, &base_ctx).await?;
|
||||
}
|
||||
Commands::Model { command } => {
|
||||
commands::model::execute(command, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::model::execute(command, &base_ctx).await?;
|
||||
}
|
||||
Commands::Server(ns) => {
|
||||
Box::pin(commands::server::dispatch(
|
||||
|
|
@ -266,21 +265,11 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
.await?;
|
||||
}
|
||||
Commands::Doctor(args) => {
|
||||
let verbose =
|
||||
args.verbose || cli_settings.output.verbosity == OutputVerbosity::Verbose;
|
||||
let exit_code = Box::pin(commands::doctor::run_doctor(
|
||||
&args,
|
||||
verbose,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
let exit_code = Box::pin(commands::doctor::run_doctor(&args, &base_ctx)).await?;
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
Commands::Version(args) => {
|
||||
commands::version::version_command(&args, &cli_settings, &cli_layer, printer)
|
||||
.await?;
|
||||
commands::version::version_command(&args, &base_ctx).await?;
|
||||
}
|
||||
Commands::Discord => {
|
||||
if process_local_json {
|
||||
|
|
@ -301,65 +290,40 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
}
|
||||
Commands::Repo(ns) => {
|
||||
commands::repo::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::repo::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Install { args, command } => {
|
||||
Box::pin(commands::install::execute(
|
||||
&args,
|
||||
command,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
process_local_json,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
Box::pin(commands::install::execute(&args, command, &base_ctx)).await?;
|
||||
}
|
||||
Commands::Uninstall(args) => {
|
||||
commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?;
|
||||
commands::uninstall::run_uninstall(&args, &base_ctx).await?;
|
||||
}
|
||||
Commands::Auth(ns) => {
|
||||
commands::auth::dispatch(ns, &cli_layer, process_local_json, printer).await?;
|
||||
commands::auth::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Pr(ns) => {
|
||||
Box::pin(commands::pr::dispatch(
|
||||
ns,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?;
|
||||
}
|
||||
Commands::Secret(ns) => {
|
||||
commands::secret::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::secret::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Settings(args) => {
|
||||
Box::pin(commands::config::execute(
|
||||
&args,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
Box::pin(commands::config::execute(&args, &base_ctx)).await?;
|
||||
}
|
||||
Commands::Workflow(ns) => {
|
||||
commands::workflow::dispatch(ns, &base_ctx)?;
|
||||
}
|
||||
Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?,
|
||||
Commands::Upgrade(args) => {
|
||||
commands::upgrade::run_upgrade(args, &cli_settings, printer).await?;
|
||||
commands::upgrade::run_upgrade(args, &base_ctx).await?;
|
||||
}
|
||||
Commands::Provider(ns) => {
|
||||
commands::provider::dispatch(ns, &cli_layer, process_local_json, printer).await?;
|
||||
commands::provider::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Sandbox { command } => {
|
||||
commands::sandbox::dispatch(
|
||||
command,
|
||||
&cli_settings,
|
||||
&cli_layer,
|
||||
process_local_json,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
commands::sandbox::dispatch(command, &base_ctx).await?;
|
||||
}
|
||||
Commands::System(ns) => {
|
||||
commands::system::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
|
||||
commands::system::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Completion(args) => {
|
||||
require_no_json_override(process_local_json)?;
|
||||
|
|
@ -505,7 +469,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;
|
||||
|
||||
|
|
@ -940,13 +904,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"));
|
||||
}
|
||||
|
|
@ -999,8 +961,6 @@ level = "warn"
|
|||
"__run-worker",
|
||||
"--server",
|
||||
"/tmp/fabro.sock",
|
||||
"--artifact-upload-token",
|
||||
"token-123",
|
||||
"--run-dir",
|
||||
"/tmp/run",
|
||||
"--run-id",
|
||||
|
|
@ -1012,7 +972,6 @@ level = "warn"
|
|||
match *cli.command.unwrap() {
|
||||
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
||||
assert_eq!(args.server, "/tmp/fabro.sock");
|
||||
assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123"));
|
||||
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
||||
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
||||
assert!(matches!(args.mode, args::RunWorkerMode::Start));
|
||||
|
|
@ -1039,7 +998,6 @@ level = "warn"
|
|||
match *cli.command.unwrap() {
|
||||
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
||||
assert_eq!(args.server, "http://127.0.0.1:3000");
|
||||
assert!(args.artifact_upload_token.is_none());
|
||||
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
||||
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
||||
assert!(matches!(args.mode, args::RunWorkerMode::Resume));
|
||||
|
|
|
|||
|
|
@ -9,15 +9,14 @@ use std::path::{Component, Path, PathBuf};
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::load::load_settings_for_workflow;
|
||||
use fabro_config::merge::combine_files;
|
||||
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
|
||||
use fabro_config::run::{parse_run_config, resolve_run_goal};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal};
|
||||
use fabro_types::settings::{Combine, SettingsLayer};
|
||||
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
|
@ -58,10 +57,11 @@ struct WorkflowScanInput {
|
|||
|
||||
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
||||
let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?;
|
||||
let merged_settings = combine_files(
|
||||
combine_files(input.user_layer, workflow_layer),
|
||||
input.args_layer.clone(),
|
||||
);
|
||||
let merged_settings = input
|
||||
.args_layer
|
||||
.clone()
|
||||
.combine(workflow_layer)
|
||||
.combine(input.user_layer);
|
||||
|
||||
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
|
||||
let target_path = root_resolution.dot_path.clone();
|
||||
|
|
|
|||
|
|
@ -52,9 +52,17 @@ pub(crate) async fn connect_server_target(target: &ServerTarget) -> Result<Clien
|
|||
connect_target_api_client_bundle(target).await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_server_target_direct(target: &str) -> Result<Client> {
|
||||
let target = target.parse::<ServerTarget>()?;
|
||||
connect_server_target(&target).await
|
||||
pub(crate) async fn connect_server_target_with_bearer(
|
||||
target: &ServerTarget,
|
||||
bearer: &str,
|
||||
) -> Result<Client> {
|
||||
build_client(
|
||||
target.clone(),
|
||||
Some(Credential::Worker(bearer.to_owned())),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_server_with_settings(
|
||||
|
|
@ -380,6 +388,8 @@ async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<(
|
|||
mod tests {
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_client::{AuthEntry, StoredSubject};
|
||||
use httpmock::Method::{GET, POST};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -500,21 +510,20 @@ mod tests {
|
|||
#[test]
|
||||
fn resolve_local_tcp_credential_does_not_fallback_to_home_dev_token() {
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp_home.path().join("dev-token"),
|
||||
"fabro_dev_abababababababababababababababababababababababababababababababab",
|
||||
)
|
||||
.unwrap();
|
||||
let original_home = std::env::var_os("FABRO_HOME");
|
||||
std::env::set_var("FABRO_HOME", temp_home.path());
|
||||
let _guard = scopeguard::guard(original_home, |original_home| match original_home {
|
||||
Some(value) => std::env::set_var("FABRO_HOME", value),
|
||||
None => std::env::remove_var("FABRO_HOME"),
|
||||
});
|
||||
|
||||
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
std::fs::write(temp_home.path().join("dev-token"), token).unwrap();
|
||||
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
|
||||
let store = AuthStore::new(temp_home.path().join("auth.json"));
|
||||
assert_eq!(
|
||||
load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())).as_deref(),
|
||||
Some(token)
|
||||
);
|
||||
|
||||
assert!(resolve_local_tcp_credential(&target).unwrap().is_none());
|
||||
assert!(
|
||||
resolve_local_tcp_credential_with_store(&target, None, &store, Utc::now())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -546,6 +555,87 @@ mod tests {
|
|||
assert!(local_dev_token_fallback(&target));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_server_target_with_bearer_sends_worker_bearer_token() {
|
||||
let server = httpmock::MockServer::start();
|
||||
let info_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/system/info")
|
||||
.header("authorization", "Bearer worker-token");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"version": "1.2.3",
|
||||
"git_sha": "abcdef0",
|
||||
"build_date": "2026-04-20",
|
||||
"profile": "release",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"storage_dir": "/tmp/fabro-worker-auth",
|
||||
"storage_engine": "slatedb",
|
||||
"runs": { "total": 0, "active": 0 },
|
||||
"uptime_secs": 42
|
||||
}));
|
||||
});
|
||||
|
||||
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
||||
let client = connect_server_target_with_bearer(&target, "worker-token")
|
||||
.await
|
||||
.unwrap();
|
||||
let info = client.get_system_info().await.unwrap();
|
||||
|
||||
assert_eq!(info.version.as_deref(), Some("1.2.3"));
|
||||
info_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_server_target_with_bearer_does_not_attempt_oauth_refresh() {
|
||||
let server = httpmock::MockServer::start();
|
||||
let info_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/system/info")
|
||||
.header("authorization", "Bearer worker-token");
|
||||
then.status(401)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"errors": [{
|
||||
"status": "401",
|
||||
"title": "Unauthorized",
|
||||
"detail": "Access token expired.",
|
||||
"code": "access_token_expired"
|
||||
}]
|
||||
}));
|
||||
});
|
||||
let refresh_mock = server.mock(|when, then| {
|
||||
when.method(POST).path("/auth/cli/refresh");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"access_token": "unused",
|
||||
"access_token_expires_at": (Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
|
||||
"refresh_token": "unused",
|
||||
"refresh_token_expires_at": (Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
|
||||
"subject": {
|
||||
"idp_issuer": "https://github.com",
|
||||
"idp_subject": "12345",
|
||||
"login": "octocat",
|
||||
"name": "Octo Cat",
|
||||
"email": "octocat@example.com"
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
||||
let client = connect_server_target_with_bearer(&target, "worker-token")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = client.get_system_info().await.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Access token expired"));
|
||||
info_mock.assert();
|
||||
assert_eq!(refresh_mock.calls(), 0);
|
||||
}
|
||||
|
||||
fn oauth_entry(
|
||||
access_token_expires_at: chrono::DateTime<chrono::Utc>,
|
||||
refresh_token_expires_at: chrono::DateTime<chrono::Utc>,
|
||||
|
|
|
|||
|
|
@ -249,13 +249,13 @@ root = "{{ env.FABRO_STORAGE_ROOT }}"
|
|||
"#,
|
||||
);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let original = std::env::var_os("FABRO_STORAGE_ROOT");
|
||||
std::env::set_var("FABRO_STORAGE_ROOT", temp.path());
|
||||
let _guard = scopeguard::guard(original, |original| match original {
|
||||
Some(value) => std::env::set_var("FABRO_STORAGE_ROOT", value),
|
||||
None => std::env::remove_var("FABRO_STORAGE_ROOT"),
|
||||
});
|
||||
|
||||
assert_eq!(storage_dir(&settings).unwrap(), temp.path());
|
||||
assert_eq!(
|
||||
local_server::storage_dir_with_lookup(&settings, &|name| {
|
||||
(name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string())
|
||||
})
|
||||
.unwrap(),
|
||||
temp.path()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue